Building OAuth Authentication for Bluesky: A Complete Guide for Web and iOS Apps

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.

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(`
window.location.href = "${callbackURL}";
`);
}

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

The Anchor iOS app implementation

The Anchor Backend implementation

AT Protocol OAuth Specification

Bluesky OAuth Client Guide

Slingshot Identity Resolution

OAuth 2.1 Draft Specification

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.