OAuth is the recommended authentication method for AT Protocol applications. It provides better security, longer-lived tokens, and a standardized flow that works across different platforms.
OAuth replaces the deprecated app password authentication method. All new applications should use OAuth.
Why OAuth?
OAuth provides several advantages over app passwords:
Better security : No need to handle user passwords
Longer token lifetimes : Especially when using private key authentication
Standardized flow : Works the same across all AT Protocol servers
Automatic token refresh : Tokens refresh transparently
Revocation support : Users can revoke access without changing their password
Choosing an OAuth Client
AT Protocol provides OAuth clients for different environments:
Browser @atproto/oauth-client-browser for single-page applications (SPAs)
Node.js @atproto/oauth-client-node for backend services and desktop apps
Core @atproto/oauth-client for custom implementations
If you have a backend server, use @atproto/oauth-client-node even for web apps. This provides better security and longer token lifetimes.
Browser OAuth Setup
For single-page applications without a backend.
Installation
npm install @atproto/oauth-client-browser @atproto/api
First, create and host your client metadata as a JSON file:
{
"client_id" : "https://my-app.com/client-metadata.json" ,
"client_name" : "My App" ,
"client_uri" : "https://my-app.com" ,
"logo_uri" : "https://my-app.com/logo.png" ,
"tos_uri" : "https://my-app.com/terms" ,
"policy_uri" : "https://my-app.com/privacy" ,
"redirect_uris" : [ "https://my-app.com/callback" ],
"scope" : "atproto" ,
"grant_types" : [ "authorization_code" , "refresh_token" ],
"response_types" : [ "code" ],
"token_endpoint_auth_method" : "none" ,
"application_type" : "web" ,
"dpop_bound_access_tokens" : true
}
The client_id must be the URL where this metadata file is hosted. It must be publicly accessible.
Initializing the Client
import { BrowserOAuthClient } from '@atproto/oauth-client-browser'
// Option 1: Load metadata from URL
const client = await BrowserOAuthClient . load ({
clientId: 'https://my-app.com/client-metadata.json' ,
handleResolver: 'https://bsky.social' // or your own PDS
})
// Option 2: Embed metadata in your app (better performance)
const client = new BrowserOAuthClient ({
clientMetadata: {
client_id: 'https://my-app.com/client-metadata.json' ,
client_name: 'My App' ,
// ... rest of metadata
},
handleResolver: 'https://bsky.social'
})
Privacy Note : Using bsky.social as a handle resolver will send user handles and IP addresses to Bluesky. For better privacy, run your own PDS or handle resolver service.
Handle Resolver Options
You can use different handle resolvers:
// Use an AT Protocol server (PDS)
const client = new BrowserOAuthClient ({
clientMetadata: metadata ,
handleResolver: 'https://my-pds.example.com'
})
// Or use DNS over HTTPS (DoH)
import { AtprotoDohHandleResolver } from '@atproto/oauth-client-browser'
const client = new BrowserOAuthClient ({
clientMetadata: metadata ,
handleResolver: new AtprotoDohHandleResolver ( 'https://cloudflare-dns.com/dns-query' )
})
Initialize on App Load
Call init() once when your app loads:
// In your app initialization
const result = await client . init ()
if ( result ) {
const { session , state } = result
if ( state != null ) {
// User just completed OAuth flow
console . log ( `Authenticated as ${ session . sub } ` )
} else {
// Restored previous session
console . log ( `Restored session for ${ session . sub } ` )
}
}
Sign In Flow
try {
await client . signIn ( 'alice.bsky.social' , {
state: 'optional-state-value' ,
signal: new AbortController (). signal // Allow cancellation
})
// This line never executes - user is redirected to OAuth server
} catch ( error ) {
// User cancelled or navigated back
console . error ( 'Sign in cancelled:' , error )
}
Using the Session
After authentication, create an Agent with the session:
import { Agent } from '@atproto/api'
const session = await client . restore ( 'did:plc:alice' )
const agent = new Agent ( session )
// Now make authenticated requests
const profile = await agent . getProfile ({ actor: agent . accountDid })
console . log ( profile . data . displayName )
Session Management
Listen for session changes:
import { TokenRefreshError , TokenRevokedError } from '@atproto/oauth-client-browser'
client . addEventListener ( 'deleted' , ( event ) => {
const { sub , cause } = event . detail
if ( cause instanceof TokenRefreshError ) {
// Session expired or refresh failed
console . log ( 'Session expired, please sign in again' )
redirectToLogin ()
} else if ( cause instanceof TokenRevokedError ) {
// User explicitly signed out
console . log ( 'Session revoked' )
}
})
client . addEventListener ( 'updated' , ( event ) => {
// Tokens were refreshed
console . log ( 'Session updated:' , event . detail . sub )
})
Node.js OAuth Setup
For backend services, traditional web apps, and desktop applications.
Installation
npm install @atproto/oauth-client-node @atproto/api
Generate Keys
For backend services, you need to generate private keys:
# Generate RSA keys
openssl genrsa -out private-key-1.pem 2048
openssl genrsa -out private-key-2.pem 2048
# Store in environment variables
export PRIVATE_KEY_1 = $( cat private-key-1.pem )
export PRIVATE_KEY_2 = $( cat private-key-2.pem )
Client Setup (Backend Service)
import { NodeOAuthClient } from '@atproto/oauth-client-node'
import { JoseKey } from '@atproto/jwk-jose'
const client = new NodeOAuthClient ({
// Client metadata that will be exposed at /client-metadata.json
clientMetadata: {
client_id: 'https://my-app.com/client-metadata.json' ,
client_name: 'My App' ,
client_uri: 'https://my-app.com' ,
logo_uri: 'https://my-app.com/logo.png' ,
tos_uri: 'https://my-app.com/terms' ,
policy_uri: 'https://my-app.com/privacy' ,
redirect_uris: [ 'https://my-app.com/callback' ],
grant_types: [ 'authorization_code' , 'refresh_token' ],
scope: 'atproto transition:generic' ,
response_types: [ 'code' ],
application_type: 'web' ,
token_endpoint_auth_method: 'private_key_jwt' ,
token_endpoint_auth_signing_alg: 'RS256' ,
dpop_bound_access_tokens: true ,
jwks_uri: 'https://my-app.com/jwks.json'
},
// Private keys for signing
keyset: await Promise . all ([
JoseKey . fromImportable ( process . env . PRIVATE_KEY_1 , 'key1' ),
JoseKey . fromImportable ( process . env . PRIVATE_KEY_2 , 'key2' )
]),
// State store (save OAuth state during auth flow)
stateStore: {
async set ( key : string , state : NodeSavedState ) {
await redis . setex ( `state: ${ key } ` , 3600 , JSON . stringify ( state ))
},
async get ( key : string ) {
const data = await redis . get ( `state: ${ key } ` )
return data ? JSON . parse ( data ) : undefined
},
async del ( key : string ) {
await redis . del ( `state: ${ key } ` )
}
},
// Session store (save user sessions)
sessionStore: {
async set ( sub : string , session : Session ) {
await db . upsertSession ( sub , session )
},
async get ( sub : string ) {
return await db . getSession ( sub )
},
async del ( sub : string ) {
await db . deleteSession ( sub )
}
}
})
Express Integration
import express from 'express'
import { Agent } from '@atproto/api'
const app = express ()
// Expose metadata and keys
app . get ( '/client-metadata.json' , ( req , res ) => {
res . json ( client . clientMetadata )
})
app . get ( '/jwks.json' , ( req , res ) => {
res . json ( client . jwks )
})
// Login endpoint
app . get ( '/login' , async ( req , res ) => {
const handle = req . query . handle as string
const state = req . query . state as string
const url = await client . authorize ( handle , {
state ,
signal: req . signal // Allow cancellation
})
res . redirect ( url . href )
})
// OAuth callback
app . get ( '/callback' , async ( req , res ) => {
const params = new URLSearchParams ( req . url . split ( '?' )[ 1 ])
try {
const { session , state } = await client . callback ( params )
// Set session cookie or JWT
req . session . userId = session . sub
// Use the session
const agent = new Agent ( session )
const profile = await agent . getProfile ({ actor: agent . accountDid })
res . json ({ success: true , profile: profile . data })
} catch ( error ) {
res . status ( 400 ). json ({ error: 'Authentication failed' })
}
})
// Restore session later
app . get ( '/api/profile' , async ( req , res ) => {
const userDid = req . session . userId
// Restore the OAuth session
const oauthSession = await client . restore ( userDid )
const agent = new Agent ( oauthSession )
// Tokens are automatically refreshed if needed
const profile = await agent . getProfile ({ actor: agent . accountDid })
res . json ( profile . data )
})
Native App Setup
For desktop apps (Electron, etc.):
import { NodeOAuthClient } from '@atproto/oauth-client-node'
// Fetch metadata from your hosted URL
const client = await NodeOAuthClient . fromClientId ({
clientId: 'https://my-app.com/client-metadata.json' ,
stateStore: {
// Store in local file or app data directory
async set ( key , state ) {
await fs . writeFile (
path . join ( app . getPath ( 'userData' ), 'oauth-state.json' ),
JSON . stringify ({ [key]: state })
)
},
// ... implement get and del
},
sessionStore: {
// Store in secure storage (e.g., keytar)
async set ( sub , session ) {
await keytar . setPassword ( 'my-app' , sub , JSON . stringify ( session ))
},
// ... implement get and del
}
})
Client metadata for native apps:
{
"client_id" : "https://my-app.com/client-metadata.json" ,
"client_name" : "My Desktop App" ,
"redirect_uris" : [ "https://my-app.com/callback" ],
"scope" : "atproto" ,
"grant_types" : [ "authorization_code" , "refresh_token" ],
"response_types" : [ "code" ],
"application_type" : "native" ,
"token_endpoint_auth_method" : "none" ,
"dpop_bound_access_tokens" : true
}
Native apps use token_endpoint_auth_method: "none" because they cannot safely store private keys. This results in shorter token lifetimes.
Advanced Features
Silent Sign-In
Attempt to sign in without user interaction:
// Browser
try {
await client . signIn ( handle , {
prompt: 'none' // Attempt SSO
})
} catch ( error ) {
if ( error . params ?. get ( 'error' ) === 'login_required' ) {
// SSO not available, show login UI
await client . signIn ( handle )
}
}
// Node.js backend
app . get ( '/login' , async ( req , res ) => {
const { handle } = req . query
const url = await client . authorize ( handle , {
prompt: 'none' ,
state: JSON . stringify ({ userId: req . session . userId })
})
res . redirect ( url . href )
})
app . get ( '/callback' , async ( req , res ) => {
const params = new URLSearchParams ( req . url . split ( '?' )[ 1 ])
try {
const result = await client . callback ( params )
// Success - user was signed in silently
res . redirect ( '/dashboard' )
} catch ( err ) {
if ( err . params ?. get ( 'error' ) === 'login_required' ) {
// Retry without prompt=none
const state = JSON . parse ( err . state )
const url = await client . authorize ( handle , { state: err . state })
res . redirect ( url . href )
}
}
})
Request Locking
Prevent concurrent token refreshes across multiple instances:
import Redlock from 'redlock'
import Redis from 'ioredis'
const redis = new Redis ()
const redlock = new Redlock ([ redis ])
const client = new NodeOAuthClient ({
// ... other options
requestLock : async ( key , fn ) => {
const lock = await redlock . lock ( key , 45000 ) // 45 seconds
try {
return await fn ()
} finally {
await redlock . unlock ( lock )
}
}
})
Custom Locales
// Only supported by OpenID-compliant OAuth servers
const url = await client . authorize ( handle , {
ui_locales: 'fr-CA fr en'
})
Development and Testing
Localhost Development
AT Protocol OAuth supports loopback clients for development:
// Browser - use IP address, not localhost
// Visit http://127.0.0.1:3000 (not http://localhost:3000)
const client = new BrowserOAuthClient ({
handleResolver: 'https://bsky.social' ,
clientMetadata: undefined // Uses loopback defaults
})
Loopback limitations:
Must use http://127.0.0.1:<port> or http://[::1]:<port>
No custom client metadata
Very short token lifetimes (typically 1 day)
No silent sign-in
For serious development, use a tunneling service like ngrok to expose your localhost and use full OAuth client metadata.
Error Handling
import {
OAuthCallbackError ,
TokenRefreshError ,
TokenRevokedError
} from '@atproto/oauth-client-node'
try {
const result = await client . callback ( params )
} catch ( error ) {
if ( error instanceof OAuthCallbackError ) {
const errorCode = error . params . get ( 'error' )
if ( errorCode === 'access_denied' ) {
console . log ( 'User denied authorization' )
} else if ( errorCode === 'login_required' ) {
console . log ( 'User needs to log in' )
}
}
}
// Session errors
client . addEventListener ( 'deleted' , ( event ) => {
const { sub , cause } = event . detail
if ( cause instanceof TokenRefreshError ) {
// Failed to refresh tokens
console . error ( 'Token refresh failed:' , cause . message )
} else if ( cause instanceof TokenRevokedError ) {
// User signed out
console . log ( 'User signed out' )
}
})
Best Practices
Use backend OAuth when possible
Backend OAuth provides better security and longer token lifetimes. Use browser OAuth only for pure client-side apps.
Implement session listeners
Always listen for session deletion events to handle token expiration gracefully. client . addEventListener ( 'deleted' , ( event ) => {
// Redirect to login or show error
window . location . href = '/login'
})
Store sessions securely
Use appropriate storage for your platform:
Browser: IndexedDB (automatic with browser client)
Backend: Encrypted database
Desktop: OS keychain (e.g., keytar)
Handle offline users
Tokens can expire while users are offline. Implement proper error handling: try {
const session = await client . restore ( userDid )
const agent = new Agent ( session )
await agent . getProfile ({ actor: agent . accountDid })
} catch ( error ) {
// Session expired, need to re-authenticate
redirectToLogin ()
}
Use state parameter
Use the state parameter to maintain context across the OAuth flow: await client . authorize ( handle , {
state: JSON . stringify ({
returnUrl: '/dashboard' ,
userId: currentUserId
})
})
Security Considerations
Never expose private keys : Keep your private keys secret and never commit them to version control.
Validate redirect URIs : Ensure your OAuth callback URL matches exactly what’s in your client metadata.
Use HTTPS in production : OAuth requires HTTPS for all redirect URIs except loopback addresses.
Migration from App Passwords
If you’re migrating from app password authentication:
// Old (deprecated)
import { AtpAgent } from '@atproto/api'
const agent = new AtpAgent ({ service: 'https://bsky.social' })
await agent . login ({
identifier: 'alice.bsky.social' ,
password: 'xxxx-xxxx-xxxx-xxxx'
})
// New (OAuth)
import { BrowserOAuthClient } from '@atproto/oauth-client-browser'
import { Agent } from '@atproto/api'
const client = new BrowserOAuthClient ({ /* ... */ })
await client . init ()
// Sign in redirects user to OAuth server
await client . signIn ( 'alice.bsky.social' )
// After redirect back
const session = await client . restore ( 'did:plc:alice' )
const agent = new Agent ( session )
Next Steps
Using the API Learn how to use the Agent API with your OAuth session
Rich Text Work with mentions and links in posts
Moderation Implement content moderation
Development Setup Set up your local development environment