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 codestate- 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 requestsrefresh_token- For getting new access tokenssub- 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:
- Cache the nonce per account (we use the DPoP keyId)
- Include it in subsequent DPoP proofs
- Handle
use_dpop_nonceerrors 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.