In Part 1, I covered my web OAuth implementation for ATProto apps, including PAR, PKCE, and DPoP tokens. Part two covers the mobile implementation using real code from the Anchor project.

Mobile OAuth requires handling WebViews, custom URL schemes, secure credential storage, and session management across app lifecycle events. This implementation simplifies these challenges while maintaining security. At least, that's the goal! I'm not a security researcher by any means so any issues you see here please let me know or open an issue. And if you are going to use what I outline here please do your own research as well.

Architecture Overview

Instead of managing OAuth tokens directly on mobile devices (which requires handling refresh tokens, DPoP proofs, and token validation), this approach uses server-side session management:

  • User completes OAuth flow in WebView at /mobile-auth

  • Server handles OAuth complexity (DPoP, refresh tokens, etc.)

  • Mobile app receives encrypted session identifier via custom URL callback

  • Mobile app uses session ID to authenticate API calls through backend

OAuth tokens never leave the server, reducing mobile attack surface. Mobile apps only handle session identifiers. This architecture is inspired by the Bookhive project, but any mistakes you encounter in the code will be mine.

My Considerations

I'll quickly re-iterate my design goals. This setup will not be good for everyone, it makes sense for my particular use-case. Hopefully you will still be able to get something useful from it. I'm building a mobile app with a web backend. Both need to be able to read and write records to the PDS server of the user, the part that we need the OAuth authentication for. The app is iOS and the backend is a Typescript Deno app running on the Valtown service. Since Anchor is still in the experimental state this simple setup is enough for me, but the OAuth setup we're discussing here should be able to scale to larger apps as well.

Backend Setup

Since my previous article I have extracted all of the OAuth logic out into separate libraries. One library that handles the routing and orchestrates everything, a separate library for the OAuth client (very similar to the official ATProto client for Node.js but specifically configured for Deno), and a library to handle the OAuth sessions.

@tijs/oauth-client-deno - JSR
@tijs/oauth-client-deno on JSR: A Deno-compatible AT Protocol OAuth client that serves as a drop-in replacement for @atproto/oauth-client-node.
https://jsr.io/@tijs/oauth-client-deno
@tijs/hono-oauth-sessions - JSR
@tijs/hono-oauth-sessions on JSR: Storage-agnostic OAuth session management for AT Protocol applications.
https://jsr.io/@tijs/hono-oauth-sessions
@tijs/atproto-oauth-hono - JSR
@tijs/atproto-oauth-hono on JSR: Complete ATProto OAuth integration for Hono applications on Val.Town. Get plug-and-play ATProto authentication with web and mobile support in just a few lines of code.
https://jsr.io/@tijs/atproto-oauth-hono

The main backend for my app location-feed-generator now ends up only having to configure the flow and it's off. Hopefully I will be able to re-use these libs in new ATProto based projects as well and save myself the pain from having to figure this all out again.

Currently the basic config looks like this:

import { createATProtoOAuth } from "jsr:@tijs/atproto-oauth-hono";

const oauth = createATProtoOAuth({
  baseUrl: "https://dropanchor.app",
  mobileScheme: "anchor-app://auth-callback",
  sessionTtl: 60 * 60 * 24 * 30, // 30 days for mobile
  storage,
});

export const oauthRoutes = oauth.routes;

This creates these endpoints:

  • /client-metadata.json - ATProto client metadata

  • /login?handle=user.bsky.social - Web OAuth flow

  • /oauth/callback - OAuth callback handler

  • /api/auth/session - Session validation (web + mobile)

  • /api/auth/mobile-start - Mobile OAuth initiation

  • /mobile/refresh-token - Mobile token refresh

  • /mobile-auth - Mobile-optimized OAuth page

Mobile OAuth Page

The /mobile-auth page loads in the iOS WebView. Here's how it generates PKCE challenges and initiates OAuth:

PKCE Challenge Generation:

const generatePKCE = async () => {
  // Generate random code verifier
  const array = new Uint8Array(96);
  crypto.getRandomValues(array);
  const codeVerifier = btoa(String.fromCharCode(...array))
    .replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");

  // Create SHA256 hash for challenge
  const encoder = new TextEncoder();
  const data = encoder.encode(codeVerifier);
  const hashBuffer = await crypto.subtle.digest("SHA-256", data);
  const hashArray = Array.from(new Uint8Array(hashBuffer));
  const codeChallenge = btoa(String.fromCharCode(...hashArray))
    .replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");

  return { codeVerifier, codeChallenge };
};

OAuth Flow Initiation:

const handleSubmit = async (e: React.FormEvent) => {
  e.preventDefault();

  const { codeVerifier, codeChallenge } = await generatePKCE();
  sessionStorage.setItem("pkce_code_verifier", codeVerifier);

  const response = await fetch("/api/auth/mobile-start", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({
      handle: handle.trim(),
      code_challenge: codeChallenge,
    }),
  });

  const data = await response.json();
  if (data.authUrl) {
    globalThis.location.href = data.authUrl;
  }
};

iOS Implementation

The iOS side uses ASWebAuthenticationSession for secure OAuth handling:

Starting the OAuth Flow:

private func startDirectOAuthFlow() {
    Task {
        let oauthURL = try await authStore.startDirectOAuthFlow()
        await MainActor.run {
            startWebAuthenticationSession(with: oauthURL)
        }
    }
}

WebView Session Setup:

private func startWebAuthenticationSession(with url: URL) {
    authSession = ASWebAuthenticationSession(
        url: url,
        callbackURLScheme: "anchor-app"
    ) { callbackURL, error in
        Task { @MainActor in
            guard let callbackURL = callbackURL else { return }

            let success = try await self.authStore.handleSecureOAuthCallback(callbackURL)
            // Handle success/error
        }
    }

    authSession?.presentationContextProvider = presentationContextProvider
    authSession?.start()
}

ASWebAuthenticationSession provides isolated browser sessions, automatic credential management, and secure callback handling.

Session Coordinator

The IronSessionMobileOAuthCoordinator handles OAuth flows while keeping tokens server-side:

Starting OAuth:

public func startDirectOAuthFlow() async throws -> URL {
    return baseURL.appendingPathComponent("/mobile-auth")
}

Processing Callback:

public func completeIronSessionOAuthFlow(callbackURL: URL) async throws -> AuthCredentialsProtocol {
    // Parse callback URL parameters
    guard let components = URLComponents(url: callbackURL, resolvingAgainstBaseURL: false),
          let queryItems = components.queryItems,
          let did = queryItems.first(where: { $0.name == "did" })?.value,
          let sealedSessionToken = queryItems.first(where: { $0.name == "session_token" })?.value else {
        throw IronSessionOAuthError.invalidCallback
    }

    // Store temp credentials and validate with backend
    let tempCredentials = AuthCredentials(
        handle: "temp",
        accessToken: "iron-session-backend-managed",
        refreshToken: "iron-session-backend-managed",
        did: did,
        pdsURL: "determined-by-backend",
        expiresAt: Date().addingTimeInterval(60 * 60 * 24),
        sessionId: sealedSessionToken
    )

    try await credentialsStorage.save(tempCredentials)

    // Get actual handle from backend
    let responseData = try await apiClient.authenticatedRequest(path: "/api/auth/session")
    let sessionData = try JSONSerialization.jsonObject(with: responseData) as? [String: Any]
    let actualHandle = sessionData?["userHandle"] as? String

    return AuthCredentials(
        handle: actualHandle ?? "user",
        accessToken: "iron-session-backend-managed",
        refreshToken: "iron-session-backend-managed",
        did: did,
        pdsURL: "determined-by-backend",
        expiresAt: Date().addingTimeInterval(60 * 60 * 24),
        sessionId: sealedSessionToken
    )
}

Key Points:

  • Mobile app never sees OAuth access tokens

  • Backend handles token management, refresh, and DPoP proofs

  • App receives sealedSessionToken that proxies authentication

Flow:

  • User taps login → App opens /mobile-auth in WebView

  • User enters credentials → Backend completes OAuth with Bluesky/PDS

  • Backend redirects to anchor-app://auth-callback?did=...&session_token=...

  • App extracts session token → Validates session with backend

Token Refresh

Token refresh happens transparently through the backend:

public func refreshIronSession() async throws -> AuthCredentialsProtocol {
    guard let currentCredentials = await credentialsStorage.load(),
          let currentSessionId = currentCredentials.sessionId else {
        throw IronSessionOAuthError.noCurrentSession
    }

    let url = baseURL.appendingPathComponent("/mobile/refresh-token")
    var request = URLRequest(url: url)
    request.httpMethod = "GET"
    request.setValue("Bearer \(currentSessionId)", forHTTPHeaderField: "Authorization")

    let (data, response) = try await session.data(for: request)

    guard let httpResponse = response as? HTTPURLResponse,
          httpResponse.statusCode == 200 else {
        throw IronSessionOAuthError.sessionRefreshFailed
    }

    return try parseRefreshResponse(data: data, currentCredentials: currentCredentials)
}

The /mobile/refresh-token endpoint handles server-side token refresh and returns a new sealed session ID.

Backend Endpoints

These endpoints handle the mobile OAuth flow:

Mobile OAuth Start:

app.post("/api/auth/mobile-start", async (c) => {
  const { handle, code_challenge } = await c.req.json();

  const authUrl = await sessions.startOAuth(handle, {
    mobile: true,
    codeChallenge: code_challenge,
  });

  return c.json({ success: true, authUrl });
});

Mobile Token Refresh:

app.get("/mobile/refresh-token", async (c) => {
  const authHeader = c.req.header("Authorization");
  if (!authHeader) {
    return c.json({ success: false, error: "Missing Authorization header" }, 401);
  }

  const result = await sessions.refreshMobileToken(authHeader);
  return c.json(result);
});

Session Validation:

app.get("/api/auth/session", async (c) => {
  const authHeader = c.req.header("Authorization");

  let result;
  if (authHeader && authHeader.startsWith("Bearer ")) {
    result = await sessions.validateMobileSession(authHeader);
  } else {
    result = await sessions.validateSession(c);
  }

  if (!result.valid || !result.did) {
    return c.json({ valid: false }, 401);
  }

  const oauthData = await sessions.getStoredOAuthData(result.did);
  return c.json({
    valid: true,
    did: result.did,
    handle: result.handle || oauthData.handle,
    displayName: result.displayName || oauthData.displayName,
    // ... other session data
  });
});

The backend detects mobile vs web clients automatically. Mobile clients use Authorization: Bearer <session_id> headers, web clients use encrypted cookies.

Credential Storage

Session identifiers are stored in iOS Keychain:

Save Credentials:

public func save(_ credentials: AuthCredentialsProtocol) async throws {
    let data = try JSONEncoder().encode(credentials)

    let query: [String: Any] = [
        kSecClass as String: kSecClassGenericPassword,
        kSecAttrService as String: service,
        kSecAttrAccount as String: account,
        kSecValueData as String: data,
        kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlockedThisDeviceOnly
    ]

    SecItemDelete(query as CFDictionary) // Remove existing
    let status = SecItemAdd(query as CFDictionary, nil)
    guard status == errSecSuccess else {
        throw CredentialsStorageError.keychainError(status)
    }
}

Load Credentials:

public func load() async -> AuthCredentials? {
    let query: [String: Any] = [
        kSecClass as String: kSecClassGenericPassword,
        kSecAttrService as String: service,
        kSecAttrAccount as String: account,
        kSecReturnData as String: true,
        kSecMatchLimit as String: kSecMatchLimitOne
    ]

    var result: CFTypeRef?
    let status = SecItemCopyMatching(query as CFDictionary, &result)

    guard status == errSecSuccess,
          let data = result as? Data,
          let credentials = try? JSONDecoder().decode(AuthCredentials.self, from: data) else {
        return nil
    }

    return credentials
}

Security Considerations

Token Isolation: OAuth access tokens never leave the backend server. Mobile devices only store sealed session identifiers.

Automatic Refresh: Backend handles OAuth token refresh automatically. Mobile apps don't need refresh token logic or DPoP handling.

Custom URL Schemes: Use app-specific URL schemes:

<!-- iOS Info.plist configuration -->
<key>CFBundleURLTypes</key>
<array>
    <dict>
        <key>CFBundleURLName</key>
        <string>AnchorAuth</string>
        <key>CFBundleURLSchemes</key>
        <array>
            <string>anchor-app</string>
        </array>
    </dict>
</array>

PKCE Protection: The mobile auth page generates PKCE challenges client-side:

// Web Crypto API PKCE generation (from MobileAuth.tsx)
const generatePKCE = async () => {
  const array = new Uint8Array(96);
  crypto.getRandomValues(array);
  const codeVerifier = btoa(String.fromCharCode(...array))
    .replace(/\+/g, "-")
    .replace(/\//g, "_")
    .replace(/=/g, "");

  const encoder = new TextEncoder();
  const data = encoder.encode(codeVerifier);
  const hashBuffer = await crypto.subtle.digest("SHA-256", data);
  const hashArray = Array.from(new Uint8Array(hashBuffer));
  const codeChallenge = btoa(String.fromCharCode(...hashArray))
    .replace(/\+/g, "-")
    .replace(/\//g, "_")
    .replace(/=/g, "");

  return { codeVerifier, codeChallenge };
};

Available Libraries

Three libraries implement this approach:

@tijs/atproto-oauth-hono - Complete OAuth integration:

import { createATProtoOAuth } from "jsr:@tijs/atproto-oauth-hono";

const oauth = createATProtoOAuth({
  baseUrl: "https://myapp.com",
  appName: "My App",
  mobileScheme: "myapp://auth-callback",
  sessionTtl: 60 * 60 * 24 * 30,
});

app.route("/", oauth.routes);

@tijs/hono-oauth-sessions - Session management:

import { HonoOAuthSessions } from "jsr:@tijs/hono-oauth-sessions";

const sessions = new HonoOAuthSessions({
  oauthClient: yourOAuthClient,
  storage: yourStorage,
  cookieSecret: process.env.COOKIE_SECRET!,
  baseUrl: "https://myapp.com",
  mobileScheme: "myapp://auth-callback",
});

@tijs/oauth-client-deno - AT Protocol OAuth client:

import { OAuthClient } from "jsr:@tijs/oauth-client-deno";

const client = new OAuthClient({
  clientId: "https://myapp.com/client-metadata.json",
  redirectUri: "https://myapp.com/oauth/callback",
  storage: yourStorage,
});

Summary

This approach simplifies mobile OAuth by:

  • Keeping OAuth tokens server-side while giving mobile apps session identifiers

  • Using ASWebAuthenticationSession for secure WebView handling

  • Handling token refresh automatically without exposing complexity to mobile clients

  • Supporting both web and mobile clients with the same backend

  • Maintaining security with PKCE, proper URL schemes, and iOS Keychain storage

Server-side session proxying provides ATProto OAuth security benefits while keeping mobile implementation simple.

Implementation Resources

  • Anchor iOS app - Complete iOS implementation

  • location-feed-generator - Backend implementation

  • Libraries: Start with @tijs/atproto-oauth-hono for complete integration

  • Works with self hosted PDS servers and bsky.social

  • Same backend serves iOS, Android, and web clients


Thanks for reading all that, I hope you got something out of it. If you end up using my libraries for your new project do let me know on Bluesky or here in the comments. That's always cool to hear! And I'd love to try out your app.