Skip to main content

What is Authentication in AT Protocol?

AT Protocol uses JWT (JSON Web Token) based authentication with a dual-token system:
  • Access Token - Short-lived token for API requests
  • Refresh Token - Long-lived token to obtain new access tokens
This approach balances security with user experience, allowing secure API access without requiring frequent re-authentication.

Authentication Flow

Creating a Session

Use the AtpAgent class for automatic session management:
import { AtpAgent } from '@atproto/api'

// Create agent
const agent = new AtpAgent({
  service: 'https://bsky.social'
})

// Login (creates session)
await agent.login({
  identifier: 'alice.bsky.social',  // Handle or DID
  password: 'hunter2'
})

// Session is now active
console.log('DID:', agent.did)
console.log('Handle:', agent.session?.handle)
console.log('Access JWT:', agent.session?.accessJwt)

// Make authenticated requests
await agent.post({
  text: 'Hello from authenticated session!',
  createdAt: new Date().toISOString()
})

Session Data Structure

interface AtpSessionData {
  accessJwt: string         // Short-lived access token
  refreshJwt: string        // Long-lived refresh token
  handle: string            // User's handle
  did: string              // User's DID
  email?: string           // User's email (if available)
  emailConfirmed?: boolean // Email verification status
  emailAuthFactor?: boolean // If email 2FA is enabled
  active: boolean          // Account status
  status?: string          // Account status details
}

Persisting Sessions

Persist sessions across app restarts:
import { AtpAgent, AtpSessionData, AtpSessionEvent } from '@atproto/api'

// Session persistence handler
const persistSession = (evt: AtpSessionEvent, session?: AtpSessionData) => {
  if (evt === 'create' || evt === 'update') {
    // Save session to storage
    localStorage.setItem('session', JSON.stringify(session))
  } else if (evt === 'expired') {
    // Clear session from storage
    localStorage.removeItem('session')
  }
}

// Create agent with persistence
const agent = new AtpAgent({
  service: 'https://bsky.social',
  persistSession
})

// On app startup, resume previous session
const savedSession = localStorage.getItem('session')
if (savedSession) {
  const session = JSON.parse(savedSession)
  await agent.resumeSession(session)
  console.log('Session resumed for:', session.handle)
}
Session Events:
  • create - New session created
  • create-failed - Session creation failed
  • update - Session refreshed
  • expired - Session expired or logged out
  • network-error - Transient refresh failure

Automatic Token Refresh

AtpAgent automatically handles token refresh:
// Create agent and login
const agent = new AtpAgent({ service: 'https://bsky.social' })
await agent.login({ identifier: 'alice.bsky.social', password: 'pass' })

// Make requests - agent automatically:
// 1. Includes access token in Authorization header
// 2. Detects when access token expires (401 response)
// 3. Uses refresh token to get new access token
// 4. Retries original request with new token

for (let i = 0; i < 100; i++) {
  // Even if access token expires during loop,
  // agent handles refresh transparently
  await agent.post({
    text: `Post ${i}`,
    createdAt: new Date().toISOString()
  })
  
  await new Promise(resolve => setTimeout(resolve, 60000)) // 1 minute
}

Manual Token Refresh

Manually refresh when needed:
// Check if session exists
if (!agent.hasSession) {
  throw new Error('No active session')
}

// The agent automatically refreshes, but you can also trigger manually:
// (This is handled internally, rarely needed in application code)

Creating Accounts

Create a new account and session:
import { AtpAgent } from '@atproto/api'

const agent = new AtpAgent({
  service: 'https://bsky.social'
})

// Create account
const response = await agent.createAccount({
  email: '[email protected]',
  handle: 'alice.bsky.social',
  password: 'secure-password-here',
  inviteCode: 'bsky-social-invite-code' // If required
})

// Session is automatically created
console.log('Account created:', response.data.did)
console.log('Handle:', response.data.handle)
console.log('Access JWT:', response.data.accessJwt)

// Can immediately make authenticated requests
await agent.api.app.bsky.actor.profile.create(
  { repo: agent.did },
  {
    displayName: 'Alice',
    description: 'New to AT Protocol!'
  }
)

Logging Out

End a session:
// Logout - revokes refresh token
await agent.logout()

// Session is cleared
console.log('Has session:', agent.hasSession) // false

Using Custom Session Managers

For advanced use cases, implement custom session management:
import { Agent, CredentialSession } from '@atproto/api'

// Create custom session manager
const session = new CredentialSession({
  service: new URL('https://bsky.social'),
  fetch: globalThis.fetch,
  persistSession: (evt, data) => {
    // Custom persistence logic
    console.log('Session event:', evt, data)
  }
})

// Use with Agent
const agent = new Agent(session)

// Login through session manager
await session.login({
  identifier: 'alice.bsky.social',
  password: 'hunter2'
})

// Make authenticated requests
await agent.api.app.bsky.feed.getTimeline()

Multi-Factor Authentication

Handle 2FA when required:
try {
  await agent.login({
    identifier: 'alice.bsky.social',
    password: 'hunter2'
  })
} catch (error) {
  if (error.message.includes('AuthFactorTokenRequired')) {
    // User has 2FA enabled
    const token = prompt('Enter your 2FA code:')
    
    await agent.login({
      identifier: 'alice.bsky.social',
      password: 'hunter2',
      authFactorToken: token  // Email code or TOTP
    })
  }
}

PDS Endpoint Discovery

AtpAgent automatically discovers and uses the correct PDS endpoint:
// Start with any AT Protocol service
const agent = new AtpAgent({
  service: 'https://bsky.social'  // Entryway/aggregator
})

await agent.login({
  identifier: 'alice.bsky.social',
  password: 'hunter2'
})

// Agent automatically discovers user's actual PDS from DID document
console.log('Service URL:', agent.serviceUrl)  
// https://bsky.social

console.log('PDS URL:', agent.pdsUrl)          
// https://morel.us-east.host.bsky.network (user's actual PDS)

// Subsequent requests go to the correct PDS
await agent.post({ text: 'Hello!', createdAt: new Date().toISOString() })

Session Security

Token Storage

Access Tokens:
  • Short-lived (typically 2 hours)
  • Can be stored in memory
  • Less sensitive than refresh tokens
Refresh Tokens:
  • Long-lived (months or longer)
  • Should be stored securely
  • Used to obtain new access tokens

Best Practices

Use secure storage mechanisms:
  • Web: httpOnly cookies or encrypted localStorage
  • Mobile: Secure enclave/keychain
  • Never expose refresh tokens to XSS attacks
Clear sessions after periods of inactivity to reduce exposure.
If refresh fails, prompt user to re-authenticate rather than silently failing.
Always call logout() to revoke refresh tokens server-side.
Never transmit credentials or tokens over unencrypted connections.

Error Handling

import { XRPCError } from '@atproto/xrpc'

try {
  await agent.login({
    identifier: 'alice.bsky.social',
    password: 'wrong-password'
  })
} catch (error) {
  if (error instanceof XRPCError) {
    if (error.status === 401) {
      console.error('Invalid credentials')
    } else if (error.error === 'AuthFactorTokenRequired') {
      console.error('2FA required')
    } else if (error.error === 'AccountTakedown') {
      console.error('Account suspended')
    }
  }
  throw error
}

Advanced: Direct XRPC Calls

For fine-grained control, use XRPC directly:
import { XrpcClient } from '@atproto/xrpc'

const client = new XrpcClient(
  (url, init) => fetch(url, init),
  'https://bsky.social'
)

// Create session
const { data } = await client.call(
  'com.atproto.server.createSession',
  {},
  {
    identifier: 'alice.bsky.social',
    password: 'hunter2'
  }
)

const { accessJwt, refreshJwt, did, handle } = data

// Make authenticated request
const timeline = await client.call(
  'app.bsky.feed.getTimeline',
  { limit: 50 },
  undefined,
  {
    headers: {
      authorization: `Bearer ${accessJwt}`
    }
  }
)

Session Lifecycle Example

Complete session management implementation:
import { AtpAgent, AtpSessionData, AtpSessionEvent } from '@atproto/api'

class SessionManager {
  private agent: AtpAgent
  
  constructor(private storageKey = 'atproto-session') {
    this.agent = new AtpAgent({
      service: 'https://bsky.social',
      persistSession: this.handleSessionEvent.bind(this)
    })
  }
  
  private handleSessionEvent(evt: AtpSessionEvent, session?: AtpSessionData) {
    switch (evt) {
      case 'create':
      case 'update':
        if (session) {
          localStorage.setItem(this.storageKey, JSON.stringify(session))
          console.log('Session saved:', session.handle)
        }
        break
        
      case 'expired':
        localStorage.removeItem(this.storageKey)
        console.log('Session expired')
        break
        
      case 'create-failed':
        console.error('Failed to create session')
        break
        
      case 'network-error':
        console.warn('Network error during session refresh')
        break
    }
  }
  
  async init() {
    const saved = localStorage.getItem(this.storageKey)
    if (saved) {
      try {
        const session = JSON.parse(saved)
        await this.agent.resumeSession(session)
        console.log('Resumed session for:', session.handle)
        return true
      } catch (error) {
        console.error('Failed to resume session:', error)
        localStorage.removeItem(this.storageKey)
      }
    }
    return false
  }
  
  async login(identifier: string, password: string) {
    await this.agent.login({ identifier, password })
    return this.agent.session
  }
  
  async logout() {
    await this.agent.logout()
  }
  
  get isAuthenticated() {
    return this.agent.hasSession
  }
  
  get api() {
    return this.agent.api
  }
}

// Usage
const sessionMgr = new SessionManager()

// On app startup
await sessionMgr.init()

if (!sessionMgr.isAuthenticated) {
  await sessionMgr.login('alice.bsky.social', 'hunter2')
}

// Use API
await sessionMgr.api.app.bsky.feed.post.create(
  { repo: sessionMgr.agent.did },
  {
    text: 'Posted with session manager!',
    createdAt: new Date().toISOString()
  }
)

Additional Resources

@atproto/api Package

NPM package documentation

XRPC Specification

XRPC protocol specification

JWT.io

Learn more about JSON Web Tokens

Server API Reference

Authentication API endpoints

Build docs developers (and LLMs) love