Skip to main content
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

Client Metadata

First, create and host your client metadata as a JSON file:
client-metadata.json
{
  "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

1

Use backend OAuth when possible

Backend OAuth provides better security and longer token lifetimes. Use browser OAuth only for pure client-side apps.
2

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'
})
3

Store sessions securely

Use appropriate storage for your platform:
  • Browser: IndexedDB (automatic with browser client)
  • Backend: Encrypted database
  • Desktop: OS keychain (e.g., keytar)
4

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()
}
5

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

Build docs developers (and LLMs) love