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.
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 WebViewUser 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 integrationWorks 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.