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.

Building OAuth Authentication for ATProto apps: Part 1, the web use-case - Building on atproto
In this follow-up OAuth implementation guide I dive a bit deeper into the actual implementation details of building authentication for your web or mobile app that builds on top of ATProto.
https://tijs.leaflet.pub/3lxmy4lk6es2g

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 endpoints

  • OAuth 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 → Dashboard

  • Redirect: 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 scheme

  • Redirect: 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.