Skip to main content

Documentation Index

Fetch the complete documentation index at: https://mintlify.com/bluesky-social/atproto/llms.txt

Use this file to discover all available pages before exploring further.

Overview

The @atproto/oauth-client-browser package provides a complete OAuth client implementation specifically designed for browser-based Single Page Applications (SPAs). It uses WebCrypto for cryptographic operations and IndexedDB for persistent storage.
This package is optimized for client-side applications without a backend server. For applications with a backend, consider using @atproto/oauth-client-node on the server side for enhanced security.

Installation

npm install @atproto/oauth-client-browser

Requirements

  • Modern browser with WebCrypto API support
  • HTTPS connection (required for WebCrypto)
  • IndexedDB support
  • ECMAScript 2020+ features
WebCrypto requires HTTPS. For local development, use http://127.0.0.1 (not http://localhost) or set up a local HTTPS server.

Quick Start

import { BrowserOAuthClient } from '@atproto/oauth-client-browser'

// Initialize the client
const client = await BrowserOAuthClient.load({
  clientId: 'https://my-app.com/client-metadata.json',
  handleResolver: 'https://bsky.social'
})

// Check for existing session or OAuth callback
const result = await client.init()

if (result) {
  const { session, state } = result
  console.log('Authenticated as:', session.did)
}

// Sign in a user
await client.signIn('alice.bsky.social', {
  state: 'my-app-state'
})
// User will be redirected to authorization server...

BrowserOAuthClient Class

Static Methods

load

static async load(
  options: BrowserOAuthClientLoadOptions
): Promise<BrowserOAuthClient>
Creates a new browser OAuth client by loading metadata from the client ID.
clientId
string
required
The client ID URL. Must be either:
  • https:// URL for discoverable clients (fetches metadata)
  • http://127.0.0.1 or http://[::1] for loopback clients (development)
handleResolver
string | URL | HandleResolver
Service URL for resolving AT Protocol handles. Commonly 'https://bsky.social'.
Using Bluesky’s resolver may leak user IPs and handles to Bluesky. Consider running your own resolver or PDS.
responseMode
'query' | 'fragment'
default:"'fragment'"
How OAuth responses are returned:
  • fragment: Parameters in URL fragment (safer, not sent to server)
  • query: Parameters in URL query string
plcDirectoryUrl
string
PLC directory URL for DID resolution. Defaults to 'https://plc.directory'.
signal
AbortSignal
Signal to cancel metadata loading.
Example:
// Production app with discoverable client ID
const client = await BrowserOAuthClient.load({
  clientId: 'https://my-app.com/client-metadata.json',
  handleResolver: 'https://my-pds.com',
  responseMode: 'fragment'
})

// Development with loopback client
const devClient = await BrowserOAuthClient.load({
  clientId: 'http://127.0.0.1:3000',
  handleResolver: 'https://bsky.social'
})

Constructor

new BrowserOAuthClient(options?: BrowserOAuthClientOptions)
Creates a client with inline metadata (avoids network request).
clientMetadata
OAuthClientMetadataInput
Client metadata object. If omitted, uses loopback client metadata based on current origin.
responseMode
'query' | 'fragment'
default:"'fragment'"
OAuth response mode.
handleResolver
string | URL | HandleResolver
Handle resolver service.
plcDirectoryUrl
string
PLC directory URL.
fetch
Fetch
Custom fetch implementation.
Example:
// Inline metadata (no network request)
const client = new BrowserOAuthClient({
  clientMetadata: {
    client_id: 'https://my-app.com/client-metadata.json',
    client_name: 'My App',
    redirect_uris: ['https://my-app.com/callback'],
    scope: 'atproto',
    grant_types: ['authorization_code', 'refresh_token'],
    response_types: ['code'],
    token_endpoint_auth_method: 'none',
    dpop_bound_access_tokens: true
  },
  handleResolver: 'https://bsky.social'
})

Instance Methods

init

async init(): Promise<
  | { session: OAuthSession; state?: string }
  | undefined
>
Initializes the client on page load. Must be called once when your app loads. This method:
  1. Checks URL for OAuth callback parameters
  2. Processes callback if present
  3. Restores the last active session if available
session
OAuthSession
The authenticated session.
state
string
The application state from authorization (only present on callbacks).
undefined
Returned when no session is available.
Example:
// In your app's main entry point
const result = await client.init()

if (result) {
  if (result.state) {
    console.log('OAuth callback:', result.state)
  } else {
    console.log('Restored session:', result.session.did)
  }
  
  // User is authenticated
  showApp(result.session)
} else {
  // No session - show login
  showLoginPage()
}
Call init() only once per page load. Calling it multiple times may cause errors or unexpected behavior.

signIn

async signIn(
  input: string,
  options?: AuthorizeOptions
): Promise<never>
Initiates sign-in flow and redirects the user. This promise never resolves (redirects immediately).
input
string
required
User’s handle (e.g., alice.bsky.social) or DID.
options.state
string
Application state to preserve.
options.prompt
'none' | 'login' | 'consent'
Authorization prompt behavior:
  • none: Silent sign-in (SSO)
  • login: Force re-authentication
  • consent: Force re-consent
options.scope
string
OAuth scopes to request.
options.ui_locales
string
Preferred UI languages (e.g., 'fr-CA fr en').
options.signal
AbortSignal
Signal to cancel authorization.
Example:
// Basic sign-in
await client.signIn('alice.bsky.social')

// With options
await client.signIn('alice.bsky.social', {
  state: JSON.stringify({ returnTo: '/dashboard' }),
  prompt: 'none', // Try SSO
  ui_locales: 'fr-CA fr en'
})

// Code after signIn() never executes (redirect occurs)
console.log('This will not be logged')

signInCallback

async signInCallback(
  params?: URLSearchParams,
  options?: CallbackOptions
): Promise<{ session: OAuthSession; state?: string }>
Manually process OAuth callback. Usually not needed (use init() instead).
params
URLSearchParams
URL parameters. Defaults to current page’s URL parameters.
options.redirect_uri
string
The redirect URI used in authorization.
session
OAuthSession
The authenticated session.
state
string
The application state.
Example:
// Custom callback handling
const params = new URLSearchParams(window.location.search)
const { session, state } = await client.signInCallback(params)

restore

async restore(
  did: string,
  refresh?: boolean | 'auto'
): Promise<OAuthSession>
Restores a previously authenticated session.
did
string
required
The user’s DID.
refresh
boolean | 'auto'
default:"'auto'"
Token refresh behavior:
  • 'auto': Refresh if expired (recommended)
  • true: Force refresh
  • false: Use cached tokens
session
OAuthSession
The restored session.
Throws:
  • TokenRefreshError if session cannot be refreshed
  • TokenRevokedError if session was revoked
Example:
// Restore session for a specific user
const session = await client.restore('did:plc:xyz')

// Use the session
const agent = new Agent(session)
const profile = await agent.getProfile({ actor: session.did })

getSession

async getSession(
  did: string
): Promise<OAuthSession | undefined>
Retrieves a session without triggering refresh.
did
string
required
The user’s DID.
session
OAuthSession
The session if available.
undefined
If no session exists for this DID.

revoke

async revoke(did: string): Promise<void>
Revokes and deletes a session.
did
string
required
The user’s DID to revoke.
Example:
await client.revoke('did:plc:xyz')
// Session is revoked on server and deleted locally

Properties

clientMetadata
ClientMetadata
The client metadata being used.
responseMode
OAuthResponseMode
The configured response mode.

Session Management

The browser client automatically manages sessions using IndexedDB:

Automatic Storage

  • Sessions persist across page reloads
  • Tokens refresh automatically when expired
  • DPoP keys stored securely in IndexedDB
  • Cross-tab synchronization using BroadcastChannel

Multi-Tab Support

// Tab 1: Sign in
const result = await client.init()

// Tab 2: Automatically notified
client.addEventListener('updated', (event) => {
  console.log('Session updated in another tab')
})

client.addEventListener('deleted', (event) => {
  console.log('Session deleted in another tab')
})

Monitoring Sessions

import {
  TokenRefreshError,
  TokenRevokedError,
  TokenInvalidError
} from '@atproto/oauth-client-browser'

client.addEventListener('updated', (event) => {
  const session = event.detail
  console.log('Tokens refreshed:', session.sub)
})

client.addEventListener('deleted', (event) => {
  const { sub, cause } = event.detail
  
  if (cause instanceof TokenRefreshError) {
    console.log('Session expired, need re-auth')
    showLoginPage()
  } else if (cause instanceof TokenRevokedError) {
    console.log('User signed out')
    showLoginPage()
  } else if (cause instanceof TokenInvalidError) {
    console.log('Invalid tokens, need re-auth')
    showLoginPage()
  }
})

OAuth Session Usage

The OAuthSession returned by the client can be used directly:

With @atproto/api

import { Agent } from '@atproto/api'

const result = await client.init()
if (result) {
  const agent = new Agent(result.session)
  
  // Make authenticated requests
  await agent.post({ text: 'Hello from browser!' })
  const profile = await agent.getProfile({ actor: agent.did })
  
  // Sign out
  await agent.signOut()
}

Direct API Calls

const session = await client.restore('did:plc:xyz')

// Make authenticated fetch
const response = await session.fetchHandler(
  '/xrpc/com.atproto.repo.listRecords',
  {
    method: 'GET'
  }
)

const data = await response.json()

Development Setup

Loopback Client (localhost)

For local development without deploying client metadata:
const client = new BrowserOAuthClient({
  // Omit clientMetadata to use loopback mode
  handleResolver: 'https://bsky.social'
})
Requirements:
  • Origin must be http://127.0.0.1:<port> or http://[::1]:<port>
  • Cannot use http://localhost:<port> (browser security)
  • Limited features: no custom branding, shorter token lifetime
Auto-redirect from localhost:
// The library automatically redirects localhost to 127.0.0.1
if (window.location.hostname === 'localhost') {
  // Redirects to http://127.0.0.1:3000
}

Production Setup

For production, host your client metadata: 1. Create 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",
    "https://my-app.com/callback"
  ],
  "scope": "atproto",
  "grant_types": ["authorization_code", "refresh_token"],
  "response_types": ["code"],
  "application_type": "web",
  "token_endpoint_auth_method": "none",
  "dpop_bound_access_tokens": true
}
2. Serve with correct headers:
// Express example
app.get('/client-metadata.json', (req, res) => {
  res.type('application/json')
  res.sendFile('client-metadata.json')
})
3. Load in your app:
const client = await BrowserOAuthClient.load({
  clientId: 'https://my-app.com/client-metadata.json',
  handleResolver: 'https://my-resolver.com'
})

Complete Example

React Application

import { BrowserOAuthClient, OAuthSession } from '@atproto/oauth-client-browser'
import { Agent } from '@atproto/api'
import { useState, useEffect } from 'react'

// Create client once
const client = await BrowserOAuthClient.load({
  clientId: 'https://my-app.com/client-metadata.json',
  handleResolver: 'https://bsky.social'
})

function App() {
  const [session, setSession] = useState<OAuthSession | null>(null)
  const [agent, setAgent] = useState<Agent | null>(null)
  
  useEffect(() => {
    // Initialize on mount
    client.init().then(result => {
      if (result) {
        setSession(result.session)
        setAgent(new Agent(result.session))
      }
    })
    
    // Listen for session changes
    const onUpdated = (e: CustomEvent) => {
      setSession(e.detail)
    }
    
    const onDeleted = (e: CustomEvent) => {
      setSession(null)
      setAgent(null)
    }
    
    client.addEventListener('updated', onUpdated)
    client.addEventListener('deleted', onDeleted)
    
    return () => {
      client.removeEventListener('updated', onUpdated)
      client.removeEventListener('deleted', onDeleted)
    }
  }, [])
  
  const signIn = async (handle: string) => {
    await client.signIn(handle, {
      state: JSON.stringify({ timestamp: Date.now() })
    })
  }
  
  const signOut = async () => {
    if (agent) {
      await agent.signOut()
    }
  }
  
  if (!session) {
    return (
      <LoginForm onSignIn={signIn} />
    )
  }
  
  return (
    <div>
      <p>Logged in as: {session.did}</p>
      <button onClick={signOut}>Sign Out</button>
      <Feed agent={agent!} />
    </div>
  )
}

Vanilla JavaScript

import { BrowserOAuthClient } from '@atproto/oauth-client-browser'
import { Agent } from '@atproto/api'

// Initialize
const client = await BrowserOAuthClient.load({
  clientId: 'https://my-app.com/client-metadata.json',
  handleResolver: 'https://bsky.social'
})

const result = await client.init()

if (result) {
  // User is authenticated
  const agent = new Agent(result.session)
  document.getElementById('login').style.display = 'none'
  document.getElementById('app').style.display = 'block'
  
  // Load user content
  const profile = await agent.getProfile({ actor: agent.did })
  document.getElementById('username').textContent = profile.data.displayName
} else {
  // Show login form
  document.getElementById('login').style.display = 'block'
  document.getElementById('app').style.display = 'none'
}

// Handle sign in
document.getElementById('signin-btn').onclick = async () => {
  const handle = document.getElementById('handle-input').value
  await client.signIn(handle)
}

// Handle sign out
document.getElementById('signout-btn').onclick = async () => {
  await client.revoke(result.session.did)
  location.reload()
}

Error Handling

try {
  const result = await client.init()
  if (result) {
    showApp(result.session)
  }
} catch (err) {
  if (err instanceof OAuthCallbackError) {
    // OAuth error from authorization server
    const error = err.params.get('error')
    const description = err.params.get('error_description')
    
    if (error === 'access_denied') {
      showMessage('Authorization denied by user')
    } else if (error === 'login_required') {
      // Silent sign-in failed, try interactive
      await client.signIn(handle)
    } else {
      showMessage(`OAuth error: ${description}`)
    }
  } else if (err instanceof TokenRefreshError) {
    // Session expired
    showMessage('Session expired. Please sign in again.')
    showLoginPage()
  } else {
    // Unexpected error
    console.error('Error initializing client:', err)
    showMessage('Failed to initialize. Please try again.')
  }
}

Security Considerations

WebCrypto Key Storage

DPoP keys are stored in IndexedDB as non-extractable CryptoKeys:
// Keys generated by WebcryptoKey.generate() are non-extractable
// This prevents JavaScript from accessing the private key material
const key = await WebcryptoKey.generate(['ES256'])
// ✓ Can sign with key
// ✗ Cannot extract private key bytes

Storage Security

  • IndexedDB: Encrypted at rest by browser
  • Same-origin policy: Data only accessible by your origin
  • No localStorage: Tokens not exposed to JavaScript string access
  • Cross-tab sync: Uses BroadcastChannel (same-origin only)

HTTPS Requirement

WebCrypto requires HTTPS in production. HTTP is only allowed for 127.0.0.1 and [::1] loopback addresses.
if (location.protocol !== 'https:' && 
    !['127.0.0.1', '[::1]'].includes(location.hostname)) {
  throw new Error('HTTPS required for WebCrypto')
}

Token Lifecycle

  • Access tokens refresh automatically before expiration
  • Refresh tokens stored encrypted in IndexedDB
  • Sessions auto-deleted on server-side revocation
  • Stale sessions cleaned up on init

Browser Compatibility

Required APIs

  • ✓ WebCrypto API (crypto.subtle)
  • ✓ IndexedDB
  • ✓ BroadcastChannel (optional, for multi-tab)
  • ✓ Web Locks API (optional, for concurrent refresh prevention)
  • ✓ ECMAScript 2020 features

Supported Browsers

  • Chrome 87+
  • Firefox 78+
  • Safari 14+
  • Edge 88+

Polyfills

No polyfills required for modern browsers. Older browsers may need:
import 'core-js/modules/esnext.symbol.dispose'
import 'core-js/modules/esnext.symbol.async-dispose'

Utility Functions

buildLoopbackClientId

import { buildLoopbackClientId } from '@atproto/oauth-client-browser'

const clientId = buildLoopbackClientId(window.location)
// Returns: 'http://localhost/'
Builds a loopback client ID from the current location.

@atproto/oauth-client

Core OAuth client (platform-agnostic)

@atproto/oauth-client-node

Node.js OAuth client for backend apps

@atproto/api

High-level AT Protocol API

@atproto/crypto

Cryptographic operations and key management

TypeScript Types

import type {
  BrowserOAuthClient,
  BrowserOAuthClientOptions,
  BrowserOAuthClientLoadOptions,
  OAuthSession,
  LoginContinuedInParentWindowError
} from '@atproto/oauth-client-browser'

Build docs developers (and LLMs) love