Note: I have since written a much more extensive and in-depth version of this guide, which is also updated with the latest state of the code referred to here. My advice would be to check that out instead, but feel free to come back here if that one is a bit too much code for your taste.
If you're building an app that integrates with Bluesky and the AT Protocol, you'll need to implement OAuth authentication. Unlike traditional social platforms with straightforward OAuth flows, AT Protocol's decentralized nature introduces unique challenges: users can be on different Personal Data Servers (PDS), handles need to be resolved to DIDs, and you need to discover OAuth endpoints dynamically.
After implementing OAuth for Anchor, a location-based check-in app for Bluesky, i figured it might help some people if i write down what i did. It took a few versions to get it right, so hopefully i can save you the trouble. This is everything i learned on how to build a robust authentication system that works for both web and mobile. Anchor is fully open-source so if you just want to looks at some code check out the SwiftUI iOS repo or the Typescript backend, tailored to run on val.town, on Github.
Before we start..
The setup outlined here is for a rather specific architecture. I wanted the mobile client and the web client to share the ATProto logic for posting checkin & address records to the network. So the mobile client does not directly post to Bluesky but it does so via the backend. Since Bluesky insists on using DPoP (Demonstrating Proof of Possesion) for your keys it makes it very complex to actually share an oauth session setup on the web with another client. You basically have to do the oauth setup twice for the web and the mobile client in one flow. If you look at the source for this implementation you'll see that while this works you might be better off with an archhitecture where your app makes the Bleuskt
The Challenge: One Protocol, Many Servers
AT Protocol's decentralized architecture means users authenticate against their own PDS rather than a central Bluesky server. This creates several challenges:
Handle Resolution: Convert handles like
user.bsky.social
to DIDs and PDS endpointsOAuth Discovery: Find authorization servers for different PDS instances
Mobile Complexity: Handle WebView authentication flows with secure token exchange
Token Management: Implement DPoP (Demonstrating Proof of Possession) for security
Most tutorials focus on simple cases, but production apps need to handle custom PDS servers, mobile authentication, and persistent sessions across app launches.
Architecture Decision: Separate Web and Mobile Flows
Rather than trying to detect client types and branch logic i eventually settled on separate flows for mobile and web login. This eliminated complexity and created more maintainable code.
Flow Separation Overview
Web OAuth Flow:
Target: Browser-based users
Session Management: HTTP cookies
Endpoints:
/api/auth/start
→/oauth/callback
→ DashboardRedirect: Back to your web application
Mobile OAuth Flow:
Target: iOS/Android apps via WebView
Session Management: Token-based with session IDs
Endpoints:
/api/auth/mobile-start
→/oauth/mobile-callback
→ Custom URL schemeRedirect:
anchor-app://auth-callback
to trigger native app
By separating the flows while sharing OAuth utilities, we get the benefits of code reuse without the complexity.
Simplifying OAuth Discovery with Slingshot
One of the biggest pain points in AT Protocol OAuth is the multi-step process of resolving handles to usable OAuth endpoints. A typical flow involves:
Normalize handle format
Try DNS resolution for custom domains
Fall back to HTTP resolution via bsky.social
Fetch DID document from PLC directory
Extract PDS endpoint from DID document
Fetch OAuth metadata from PDS
Discover authorization server endpoints
That's 5+ API calls just to start authentication! We solved this using Slingshot, a third-party service that consolidates identity resolution into a single API call:
// Before: 5+ API calls for OAuth setup
// After: Just 2 API calls total
async function setupOAuthWithSlingshot(handle: string) {
// Single call gets DID, PDS, and signing key
const identity = await resolveIdentifierWithSlingshot(handle);
// Standard OAuth discovery for authorization endpoints
const endpoints = await discoverOAuthEndpointsFromPDS(identity.pds);
return {
handle: identity.handle,
did: identity.did,
pdsEndpoint: identity.pds,
authorizationEndpoint: endpoints.authorizationEndpoint,
tokenEndpoint: endpoints.tokenEndpoint,
};
}
Slingshot handles Unicode normalization, supports both handles and DIDs as input, and works with custom PDS servers—making it an ideal solution for production applications.
Implementing the Mobile OAuth Flow
Mobile OAuth for AT Protocol requires careful handling since you can't redirect directly to native apps from arbitrary PDS servers. Here's our approach:
1. OAuth Client Metadata
First, serve OAuth client metadata that AT Protocol servers can validate:
{
"client_id": "https://yourapp.com/client-metadata.json",
"client_name": "Your App Name",
"redirect_uris": ["https://yourapp.com/oauth/callback"],
"scope": "atproto transition:generic",
"grant_types": ["authorization_code", "refresh_token"],
"response_types": ["code"],
"dpop_bound_access_tokens": true
}
2. Mobile Authentication Initiation
When users tap "Sign in with Bluesky" in your iOS app, open a WebView to your mobile auth endpoint:
struct OAuthWebView: UIViewRepresentable {
let url: URL // https://yourapp.com/mobile-auth
let onAuthComplete: (URL) -> Void
func makeUIView(context: Context) -> WKWebView {
let webView = WKWebView()
webView.navigationDelegate = context.coordinator
return webView
}
// Watch for custom URL scheme redirects
func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction) {
if let url = navigationAction.request.url,
url.scheme == "your-app" && url.host == "auth-callback" {
onAuthComplete(url)
return .cancel
}
return .allow
}
}
3. Secure Token Exchange
The key insight for mobile OAuth is using a two-step process that follows OAuth 2.1 standards:
OAuth Callback: Your server exchanges the authorization code for tokens and stores them temporarily with a session ID
Token Exchange: The mobile app uses the session ID to retrieve the actual tokens securely
// Mobile OAuth callback - only returns session ID (no secrets in URL)
export async function handleMobileOAuthCallback(request: Request) {
// Exchange authorization code for tokens with PDS
const tokens = await exchangeCodeForTokens(authCode);
// Store tokens securely on your server
const sessionId = crypto.randomUUID();
await storeTemporarySession(sessionId, tokens);
// Return page that redirects to app with ONLY session ID
const callbackURL = `your-app://auth-callback?code=${sessionId}`;
return new Response(`
<script>window.location.href = "${callbackURL}";</script>
`);
}
4. iOS Token Retrieval
When your iOS app receives the callback, exchange the session ID for tokens:
func handleOAuthCallback(_ callbackURL: URL) async throws -> Bool {
// Parse session ID from callback URL
guard let code = extractCode(from: callbackURL) else {
throw AuthError.invalidCallback
}
// Exchange session ID for actual tokens
let credentials = try await exchangeAuthorizationCode(code)
// Store in iOS Keychain for persistence
try await storage.save(credentials)
return true
}
Security Considerations
DPoP Implementation
AT Protocol requires DPoP (Demonstrating Proof of Possession) for secure token binding. This involves creating JWT proofs for each API request:
async function generateDPoPProof(
method: string,
url: string,
privateKey: CryptoKey,
accessToken?: string
) {
const header = {
typ: "dpop+jwt",
alg: "ES256",
jwk: await exportJWK(publicKey)
};
const payload = {
htm: method,
htu: url,
iat: Math.floor(Date.now() / 1000),
jti: crypto.randomUUID()
};
// Sign and return JWT
}
iOS Keychain Storage
For mobile apps, store credentials securely in the iOS Keychain rather than UserDefaults:
public func save(_ credentials: AuthCredentials) async throws {
let data = try JSONEncoder().encode(credentials)
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: "YourApp",
kSecAttrAccount as String: "BlueskyCredentials",
kSecValueData as String: data,
kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlockedThisDeviceOnly
]
SecItemAdd(query as CFDictionary, nil)
}
Session Persistence and Token Refresh
One major advantage of our approach is persistent authentication across app launches. Store refresh tokens securely and implement automatic token refresh:
@Observable
public final class AuthStore {
public var isAuthenticated: Bool = false
private let storage: CredentialsStorageProtocol
public func restoreSession() async {
if let credentials = try? await storage.load(),
!isTokenExpired(credentials.accessToken) {
self.isAuthenticated = true
}
}
private func refreshTokenIfNeeded() async throws {
// Implement token refresh logic using stored refresh token
}
}
Benefits of This Architecture
1. Clear Separation of Concerns
Web and mobile flows are completely independent
No complex branching logic or mobile detection
Easier to test and maintain each flow separately
2. Standards Compliance
OAuth 2.1 + PKCE implementation
DPoP for enhanced security
Follows AT Protocol specifications exactly
3. Multi-PDS Support
Works with any AT Protocol-compatible PDS
Supports custom domains and personal servers
Handles edge cases in handle resolution
4. Production Ready
Persistent sessions across app launches
Secure credential storage
Automatic token refresh
Comprehensive error handling
Getting Started
To implement this in your own app:
Set up OAuth client metadata at a public endpoint
Implement handle resolution using Slingshot or custom logic
Create separate endpoints for web and mobile OAuth flows
Add custom URL scheme to your iOS app for callbacks
Implement secure token storage using iOS Keychain
Test with multiple PDS servers to ensure compatibility
The key insight is that AT Protocol's complexity comes from its decentralized nature, but by building the right abstractions and separating concerns clearly, you can create a robust authentication system that works reliably across the entire network.
Resources
Building OAuth for decentralized protocols isn't trivial, but with the right architecture decisions and tools like Slingshot, you can create authentication flows that are both secure and user-friendly. The investment in proper OAuth implementation pays dividends in user trust and app reliability across the entire AT Protocol ecosystem.