Building a Bluesky client app? OAuth is the modern, secure way to authenticate users. This guide covers everything you need to implement OAuth in your Bluesky application, including PKCE, DPoP, PAR, and the lessons we learned building Skyscraper for iOS.

Bluesky's OAuth implementation follows OAuth 2.0 best practices with additional security requirements like mandatory DPoP (Demonstrating Proof-of-Possession). Let's break it down.

Why OAuth Instead of App Passwords?

Bluesky supports both app passwords and OAuth, but OAuth is preferred for client applications:

  • No credential storage - You don't store the user's password
  • Granular scopes - Request only the permissions you need
  • Token expiration - Access tokens expire and can be revoked
  • User control - Users can see and revoke app access
  • Security best practices - DPoP prevents token theft

OAuth Client Types

Bluesky supports three types of OAuth clients:

1. Confidential Clients (Web Services)

Traditional backend applications that can securely store secrets. These use client assertions with JWT for authentication.

2. Public Clients - Browser Apps

Single-page applications that store DPoP keys in non-exportable IndexedDB and tokens in browser storage.

3. Public Clients - Mobile/Desktop Apps

Native applications like Skyscraper that use app links or custom URI schemes for callbacks and secure file/database storage for tokens.

Client Metadata: Your App's Identity

Every OAuth client must publish a JSON metadata document at an HTTPS URL. This URL becomes your client_id.

{
    "client_id": "https://yourapp.com/oauth/client-metadata.json",
    "client_name": "Your App Name",
    "client_uri": "https://yourapp.com",
    "logo_uri": "https://yourapp.com/logo.png",
    "tos_uri": "https://yourapp.com/terms",
    "policy_uri": "https://yourapp.com/privacy",
    "redirect_uris": [
        "https://yourapp.com/oauth/callback"
    ],
    "scope": "atproto transition:generic transition:chat.bsky",
    "grant_types": ["authorization_code", "refresh_token"],
    "response_types": ["code"],
    "token_endpoint_auth_method": "none",
    "application_type": "native",
    "dpop_bound_access_tokens": true
}

Key fields:

  • client_id - The URL where this JSON is hosted
  • redirect_uris - Where users return after authorization
  • scope - Must include "atproto" for Bluesky access
  • dpop_bound_access_tokens - Must be true (DPoP is mandatory)

The OAuth Flow: Step by Step

Step 1: Handle Resolution

Start with the user's handle (e.g., "alice.bsky.social") and resolve it to their DID using Bluesky's identity resolver. Verify the handle bidirectionally against the DID document.

Step 2: Authorization Server Discovery

Fetch the user's PDS (Personal Data Server) OAuth metadata from:

  • /.well-known/oauth-protected-resource
  • /.well-known/oauth-authorization-server

This tells you the authorization endpoints for their specific PDS.

Step 3: Generate PKCE Challenge

PKCE (Proof Key for Code Exchange) prevents authorization code interception:

// Generate a random 32-byte code verifier
const codeVerifier = generateRandomBytes(32);

// Create SHA-256 hash for code challenge
const codeChallenge = base64url(sha256(codeVerifier));

Store the verifier securely—you'll need it for token exchange.

Step 4: Generate DPoP Keypair

DPoP requires a unique ES256 (P-256 elliptic curve) keypair per session:

// Generate P-256 keypair
const keypair = await crypto.subtle.generateKey(
    { name: "ECDSA", namedCurve: "P-256" },
    true,
    ["sign", "verify"]
);

// Store private key securely (Keychain on iOS)

In Skyscraper, we store DPoP private keys in the iOS Keychain with a unique keyId per account.

Step 5: Pushed Authorization Request (PAR)

Instead of passing parameters via URL, POST them to the authorization server:

POST /oauth/par
Content-Type: application/x-www-form-urlencoded
DPoP: <dpop_proof_jwt>

client_id=https://yourapp.com/oauth/client-metadata.json
&response_type=code
&code_challenge=<code_challenge>
&code_challenge_method=S256
&state=<random_state>
&redirect_uri=https://yourapp.com/oauth/callback
&scope=atproto transition:generic
&login_hint=alice.bsky.social

The server returns a request_uri token.

Step 6: Redirect to Authorization

Open the authorization URL in the user's browser:

https://pds.example.com/oauth/authorize
    ?request_uri=urn:ietf:params:oauth:request_uri:abc123
    &client_id=https://yourapp.com/oauth/client-metadata.json

The user logs in and approves your app.

Step 7: Handle Callback

After approval, the user is redirected to your redirect_uri with:

  • code - The authorization code
  • state - Your original state (verify this!)
  • iss - The issuer (authorization server)

Step 8: Token Exchange

Exchange the authorization code for tokens:

POST /oauth/token
Content-Type: application/x-www-form-urlencoded
DPoP: <dpop_proof_jwt>

grant_type=authorization_code
&code=<authorization_code>
&redirect_uri=https://yourapp.com/oauth/callback
&client_id=https://yourapp.com/oauth/client-metadata.json
&code_verifier=<original_code_verifier>

Response includes:

  • access_token - For API requests
  • refresh_token - For getting new access tokens
  • sub - The user's DID (critical to verify!)
  • expires_in - Token lifetime

Step 9: Verify the DID

Critical security step: Verify that the sub (DID) in the response matches the account you expected. This prevents account confusion attacks.

DPoP: Proof of Possession

DPoP is mandatory for Bluesky OAuth. Every request must include a DPoP proof—a signed JWT proving you possess the private key.

DPoP Proof Structure

// Header
{
    "typ": "dpop+jwt",
    "alg": "ES256",
    "jwk": { /* public key */ }
}

// Payload
{
    "jti": "unique-random-id",
    "htm": "POST",
    "htu": "https://pds.example.com/oauth/token",
    "iat": 1703123456,
    "nonce": "server-provided-nonce",
    "ath": "base64url(sha256(access_token))"  // for API requests
}

Nonce Handling

The server provides a nonce via the DPoP-Nonce response header. You must:

  1. Cache the nonce per account (we use the DPoP keyId)
  2. Include it in subsequent DPoP proofs
  3. Handle use_dpop_nonce errors by retrying with the new nonce

API Requests with DPoP

When making authenticated API requests:

GET /xrpc/app.bsky.feed.getTimeline
Authorization: DPoP <access_token>
DPoP: <dpop_proof_with_ath>

Note: Use DPoP scheme in Authorization header, not Bearer.

How We Implemented OAuth in Skyscraper

Here's how we structured OAuth in our iOS app:

Key Components

  • OAuthService - Orchestrates the entire OAuth flow
  • DPoPManager - Generates and manages DPoP keys and proofs
  • KeychainManager - Securely stores keys, tokens, and OAuth state
  • AccountManager - Tracks multiple accounts and auth methods

Security Decisions

  • Per-account DPoP keys - Each logged-in account has its own keypair
  • Keychain storage - All sensitive data in iOS Keychain
  • 10-minute state expiration - OAuth state expires quickly
  • Automatic nonce caching - Per-account nonce management
  • Retry on nonce errors - Transparent handling of use_dpop_nonce

Session Structure

Our session model supports both OAuth and app password authentication:

struct ATProtoSession {
    let did: String
    let handle: String
    let accessJwt: String
    let refreshJwt: String
    let pdsURL: String?

    // OAuth-specific
    let authType: AuthType  // .oauth or .appPassword
    let dpopKeyId: String?  // Keychain reference
    let tokenExpiresAt: Date?
    let oauthIssuer: String?
}

Multi-Account Support

With OAuth, users can log into multiple accounts. Each account has:

  • Its own DPoP keypair (identified by keyId)
  • Its own cached DPoP nonce
  • Its own token expiration tracking
  • Independent refresh token handling

Token Refresh

Access tokens expire. Use the refresh token to get new ones:

POST /oauth/token
Content-Type: application/x-www-form-urlencoded
DPoP: <dpop_proof_jwt>

grant_type=refresh_token
&refresh_token=<refresh_token>
&client_id=https://yourapp.com/oauth/client-metadata.json

Store the new tokens and update your session.

Security Best Practices

  • Never share DPoP keys - Each device/session needs its own keypair
  • Validate state parameter - Prevents CSRF attacks
  • Verify the sub claim - Ensure you got the right account
  • Implement timeouts - Protect against hanging requests
  • Validate URLs - Prevent SSRF attacks
  • Don't store tokens in cookies - Use secure storage
  • Handle nonce errors gracefully - Retry with new nonce

Common Pitfalls

  • Forgetting DPoP on requests - Every authenticated request needs it
  • Wrong nonce scope - Authorization server and PDS have separate nonces
  • Not verifying sub - Security vulnerability if skipped
  • Reusing DPoP keys across devices - Each device needs unique keys
  • Ignoring token expiration - Refresh before tokens expire

Frequently Asked Questions

Does Bluesky support OAuth?

Yes! Bluesky supports OAuth 2.0 with PKCE and DPoP. It's the recommended authentication method for client apps.

What is DPoP?

DPoP (Demonstrating Proof-of-Possession) binds access tokens to a client's cryptographic key, preventing token theft.

What is PKCE?

PKCE (Proof Key for Code Exchange) prevents authorization code interception by requiring a code verifier that only the original client knows.

What is PAR?

PAR (Pushed Authorization Request) lets you POST authorization parameters instead of putting them in URLs, improving security.