OAuth and Authentication: The Full Pipeline from Keychain to Token Refresh

A deep dive into Claude Code's authentication architecture — API Keys, OAuth 2.0, JWT, macOS Keychain integration, and token refresh scheduling

Setting the Stage

When you first run the claude command, the system opens a browser to guide you through OAuth login. A few seconds later the terminal displays "Login successful" and you start coding. Hours later, the token expires — but you don't notice a thing, because the system silently refreshes it in the background. When you switch to remote Bridge mode, the JWT is automatically decoded, scheduled, and refreshed 5 minutes before expiration. Behind all of this is a full-pipeline architecture covering three authentication methods: API Key, OAuth 2.0, and JWT.

The core questions this architecture must answer are:

  1. Diverse credential sources: How do you unify handling of environment variable API Keys, OAuth Tokens, tokens passed via file descriptors, and JWTs in Bridge mode?
  2. Secure storage: Where are tokens stored? macOS uses Keychain, Linux uses plaintext files — how do you abstract away this difference?
  3. Lifecycle management: What happens when a token expires? How do you avoid race conditions when multiple processes try to refresh simultaneously?
  4. Cold start optimization: macOS Keychain reads take ~32ms each, and two sequential reads cost ~65ms — how do you optimize this?

This article starts with a panoramic view of authentication methods, then progressively dives into Keychain integration, the OAuth flow, token refresh scheduling, JWT management in Bridge mode, and finally paints the complete picture of Claude Code's authentication system.

Authentication Methods Overview

Claude Code supports multiple authentication methods, with the following priority from highest to lowest:

...

The authentication source determination logic lives in the getAuthTokenSource() function in src/utils/auth.ts:

src/utils/auth.ts
TypeScript
1export function getAuthTokenSource() {
2 // --bare: API-key-only. apiKeyHelper is the only allowed bearer-token source
3 if (isBareMode()) {
4 if (getConfiguredApiKeyHelper()) {
5 return { source: 'apiKeyHelper' as const, hasToken: true }
6 }
7 return { source: 'none' as const, hasToken: false }
8 }
9
10 if (process.env.ANTHROPIC_AUTH_TOKEN && !isManagedOAuthContext()) {
11 return { source: 'ANTHROPIC_AUTH_TOKEN' as const, hasToken: true }
12 }
13
14 if (process.env.CLAUDE_CODE_OAUTH_TOKEN) {
15 return { source: 'CLAUDE_CODE_OAUTH_TOKEN' as const, hasToken: true }
16 }
17
18 // Check for OAuth Token passed via file descriptor (or CCR disk fallback)
19 const oauthTokenFromFd = getOAuthTokenFromFileDescriptor()
20 if (oauthTokenFromFd) {
21 if (process.env.CLAUDE_CODE_OAUTH_TOKEN_FILE_DESCRIPTOR) {
22 return { source: 'CLAUDE_CODE_OAUTH_TOKEN_FILE_DESCRIPTOR', hasToken: true }
23 }
24 return { source: 'CCR_OAUTH_TOKEN_FILE', hasToken: true }
25 }
26
27 const oauthTokens = getClaudeAIOAuthTokens()
28 if (shouldUseClaudeAIAuth(oauthTokens?.scopes) && oauthTokens?.accessToken) {
29 return { source: 'claude.ai' as const, hasToken: true }
30 }
31
32 return { source: 'none' as const, hasToken: false }
33}

This function has a key constraint: managed context isolation. When Claude Desktop or CCR (Claude Code Remote) launches the CLI via OAuth, the system checks isManagedOAuthContext() to prevent falling back to the user's local apiKeyHelper or environment variable API Key, preventing cross-context credential leakage.

Three Core Authentication Modes

ModeSourceRefreshableUse Case
API KeyANTHROPIC_API_KEY env variable or apiKeyHelperNoCI/CD, third-party integrations, --bare mode
OAuth 2.0Browser authorization flow + Keychain storageYesInteractive terminal, Claude.ai subscribers
JWTIssued by Bridge /bridge endpointYes (scheduled refresh)Remote Bridge mode, Claude Desktop

OAuth 2.0 is the most central authentication method and the focus of this article. Anthropic's OAuth implementation follows the RFC 7636 (PKCE) extension and supports both automatic (browser callback) and manual (paste code) authorization code acquisition methods.

macOS Keychain Integration

Storage Architecture

Token storage abstracts away platform differences through the SecureStorage interface:

src/utils/secureStorage/index.ts
TypeScript
1export function getSecureStorage(): SecureStorage {
2 if (process.platform === 'darwin') {
3 return createFallbackStorage(macOsKeychainStorage, plainTextStorage)
4 }
5 // TODO: add libsecret support for Linux
6 return plainTextStorage
7}

On macOS, a "Keychain first, plaintext file fallback" FallbackStorage strategy is used. This isn't a simple "if A fails try B" — createFallbackStorage also handles data migration across storage backends:

src/utils/secureStorage/fallbackStorage.ts
TypeScript
1update(data: SecureStorageData): { success: boolean; warning?: string } {
2 const primaryDataBefore = primary.read()
3 const result = primary.update(data)
4
5 if (result.success) {
6 // Delete secondary on first successful migration to primary
7 // This preserves credentials when host and container share .claude
8 if (primaryDataBefore === null) {
9 secondary.delete()
10 }
11 return result
12 }
13
14 const fallbackResult = secondary.update(data)
15 if (fallbackResult.success) {
16 // Primary write failed but primary may still hold stale entries
17 // read() prefers primary, so stale entries shadow the freshly-written secondary data
18 // This causes use of an old refresh token already rotated by the server -> /login loop
19 if (primaryDataBefore !== null) {
20 primary.delete()
21 }
22 return { success: true, warning: fallbackResult.warning }
23 }
24
25 return { success: false }
26}

There's an elegant bug fix (#30337) in this code: when Keychain write fails and falls back to file storage, if the stale Keychain entry isn't deleted, read() would preferentially return the expired refresh token from Keychain, trapping the user in a /login loop.

Keychain Read/Write Implementation

macOS Keychain reads and writes are performed through the security CLI tool. Writes face a 4096-byte stdin buffer limit:

src/utils/secureStorage/macOsKeychainStorage.ts
TypeScript
1const SECURITY_STDIN_LINE_LIMIT = 4096 - 64
2
3// L97-146 update method
4update(data: SecureStorageData): { success: boolean; warning?: string } {
5 clearKeychainCache()
6 const jsonString = jsonStringify(data)
7 const hexValue = Buffer.from(jsonString, 'utf-8').toString('hex')
8
9 const command = `add-generic-password -U -a "${username}" -s "${storageServiceName}" -X "${hexValue}"\n`
10
11 if (command.length <= SECURITY_STDIN_LINE_LIMIT) {
12 // Prefer stdin to prevent process monitoring tools (e.g., CrowdStrike) from seeing credentials
13 result = execaSync('security', ['-i'], { input: command, ... })
14 } else {
15 // Fall back to argv when exceeding stdin limit
16 result = execaSync('security', ['add-generic-password', '-U', '-a', ...], ...)
17 }
18}

Note that credentials are hex-encoded before being passed in — this isn't encryption, but rather to avoid special characters in JSON causing shell-level parsing issues. Using security -i (stdin mode) is a security consideration: endpoint security software like CrowdStrike monitors process command-line arguments, and stdin passing means they only see security -i rather than the actual credentials.

Caching and Stale-While-Error

The synchronous path for Keychain reads takes about ~500ms each (security CLI spawn). When many MCP connectors authenticate simultaneously, the lack of caching would block the event loop for several seconds. The system therefore implements TTL-based caching with a stale-while-error strategy:

src/utils/secureStorage/macOsKeychainHelpers.ts
TypeScript
1export const KEYCHAIN_CACHE_TTL_MS = 30_000
2
3// src/utils/secureStorage/macOsKeychainStorage.ts (L28-66)
4read(): SecureStorageData | null {
5 const prev = keychainCacheState.cache
6 if (Date.now() - prev.cachedAt < KEYCHAIN_CACHE_TTL_MS) {
7 return prev.data // Return cached value within 30s
8 }
9
10 try {
11 // ...execute security find-generic-password...
12 } catch (_e) {
13 // Stale-while-error: if stale data exists and refresh fails, keep using stale data
14 // Prevents a single security spawn failure from causing an "unauthenticated" state
15 if (prev.data !== null) {
16 keychainCacheState.cache = { data: prev.data, cachedAt: Date.now() }
17 return prev.data
18 }
19 keychainCacheState.cache = { data: null, cachedAt: Date.now() }
20 return null
21 }
22}

The 30-second TTL is a deliberate trade-off: OAuth tokens typically expire on the order of hours, and the only cross-process writer is another Claude Code instance's /login or token refresh. 30 seconds of staleness is perfectly acceptable in this scenario.

startKeychainPrefetch: Cold Start Optimization

On macOS, each startup requires reading two Keychain entries: OAuth Token (~32ms) and Legacy API Key (~33ms). Sequential reads mean ~65ms of blocking. startKeychainPrefetch() parallelizes these two reads and runs them concurrently with main.tsx's module loading:

src/utils/secureStorage/keychainPrefetch.ts
TypeScript
1export function startKeychainPrefetch(): void {
2 if (process.platform !== 'darwin' || prefetchPromise || isBareMode()) return
3
4 // Two child processes start immediately in parallel, running concurrently with main.tsx imports
5 const oauthSpawn = spawnSecurity(
6 getMacOsKeychainStorageServiceName(CREDENTIALS_SERVICE_SUFFIX),
7 )
8 const legacySpawn = spawnSecurity(getMacOsKeychainStorageServiceName())
9
10 prefetchPromise = Promise.all([oauthSpawn, legacySpawn]).then(
11 ([oauth, legacy]) => {
12 if (!oauth.timedOut) primeKeychainCacheFromPrefetch(oauth.stdout)
13 if (!legacy.timedOut) legacyApiKeyPrefetch = { stdout: legacy.stdout }
14 },
15 )
16}

This code is called at the top of main.tsx — even before most imports:

src/main.tsx
TypeScript
1// These side effects must run before all other imports:
2// 1. profileCheckpoint marks entry time
3// 2. startMdmRawRead starts the MDM subprocess
4// 3. startKeychainPrefetch starts two macOS Keychain reads
5import { profileCheckpoint } from './utils/startupProfiler.js';
6profileCheckpoint('main_tsx_entry');
7import { startMdmRawRead } from './utils/settings/mdm/rawRead.js';
8startMdmRawRead();
9import { ensureKeychainPrefetchCompleted, startKeychainPrefetch }
10 from './utils/secureStorage/keychainPrefetch.js';
11startKeychainPrefetch();

There's a subtle module design constraint here: keychainPrefetch.ts cannot import execa. Because Bun's ESM wrapper executes the entire module initialization chain when any symbol is accessed — the execa -> human-signals -> cross-spawn chain takes ~58ms of synchronous initialization, which would completely negate the prefetch benefits. So the prefetch module uses native child_process.execFile instead.

sequenceDiagram
  participant M as main.tsx
  participant P as keychainPrefetch
  participant K1 as security CLI (OAuth)
  participant K2 as security CLI (Legacy)
  participant I as Module Imports (~65ms)

  M->>P: startKeychainPrefetch()
  P->>K1: execFile('security', [...]) non-blocking
  P->>K2: execFile('security', [...]) non-blocking

  rect rgb(30, 30, 50)
    Note over M,I: Parallel execution
    M->>I: import module chain (~65ms)
    K1-->>P: OAuth Token (~32ms)
    K2-->>P: Legacy API Key (~33ms)
  end

  P->>P: primeKeychainCacheFromPrefetch()
  M->>P: ensureKeychainPrefetchCompleted() (near-zero cost)

primeKeychainCacheFromPrefetch only writes to the cache if it hasn't been touched — if a sync read() or update() has already executed, the prefetch result is discarded to preserve authoritativeness:

src/utils/secureStorage/macOsKeychainHelpers.ts
TypeScript
1export function primeKeychainCacheFromPrefetch(stdout: string | null): void {
2 if (keychainCacheState.cache.cachedAt !== 0) return // Cache already touched
3 let data: SecureStorageData | null = null
4 if (stdout) {
5 try {
6 data = JSON.parse(stdout) // Note: intentionally not using jsonParse() here
7 } catch {
8 return // Malformed prefetch result — let sync read() re-fetch
9 }
10 }
11 keychainCacheState.cache = { data, cachedAt: Date.now() }
12}

OAuth 2.0 Flow

PKCE Authorization Code Flow

Claude Code implements the full OAuth 2.0 Authorization Code Flow with PKCE (RFC 7636). The core implementation lives in the src/services/oauth/ directory, consisting of four files:

  • crypto.ts — PKCE cryptographic primitives (code_verifier, code_challenge, state)
  • client.ts — OAuth client (URL building, token exchange, refresh, profile fetching)
  • auth-code-listener.ts — Local HTTP server that captures the authorization code callback
  • index.tsOAuthService class orchestrating the entire flow

The PKCE cryptography is quite concise:

src/services/oauth/crypto.ts
TypeScript
1import { createHash, randomBytes } from 'crypto'
2
3function base64URLEncode(buffer: Buffer): string {
4 return buffer
5 .toString('base64')
6 .replace(/\+/g, '-')
7 .replace(/\//g, '_')
8 .replace(/=/g, '')
9}
10
11export function generateCodeVerifier(): string {
12 return base64URLEncode(randomBytes(32))
13}
14
15export function generateCodeChallenge(verifier: string): string {
16 const hash = createHash('sha256')
17 hash.update(verifier)
18 return base64URLEncode(hash.digest())
19}
20
21export function generateState(): string {
22 return base64URLEncode(randomBytes(32))
23}

The code_verifier is 32 bytes of random data, and the code_challenge is its SHA-256 hash. This ensures that even if the authorization code is intercepted, an attacker cannot complete the token exchange — because they don't have the original code_verifier.

Dual-Path Authorization: Automatic vs Manual

OAuthService.startOAuthFlow() supports two authorization code acquisition methods simultaneously:

src/services/oauth/index.ts
TypeScript
1async startOAuthFlow(
2 authURLHandler: (url: string, automaticUrl?: string) => Promise<void>,
3 options?: { loginWithClaudeAi?: boolean; skipBrowserOpen?: boolean; ... },
4): Promise<OAuthTokens> {
5 // 1. Start local HTTP server
6 this.authCodeListener = new AuthCodeListener()
7 this.port = await this.authCodeListener.start()
8
9 // 2. Generate PKCE values and state
10 const codeChallenge = crypto.generateCodeChallenge(this.codeVerifier)
11 const state = crypto.generateState()
12
13 // 3. Build two URLs: manual and automatic
14 const manualFlowUrl = client.buildAuthUrl({ ...opts, isManual: true })
15 const automaticFlowUrl = client.buildAuthUrl({ ...opts, isManual: false })
16
17 // 4. Wait for authorization code (whichever path completes first)
18 const authorizationCode = await this.waitForAuthorizationCode(
19 state,
20 async () => {
21 if (options?.skipBrowserOpen) {
22 await authURLHandler(manualFlowUrl, automaticFlowUrl)
23 } else {
24 await authURLHandler(manualFlowUrl) // Show manual option to user
25 await openBrowser(automaticFlowUrl) // Try automatic flow
26 }
27 },
28 )
29
30 // 5. Exchange authorization code for tokens
31 const tokenResponse = await client.exchangeCodeForTokens(
32 authorizationCode, state, this.codeVerifier, this.port!,
33 !isAutomaticFlow, options?.expiresIn,
34 )
35
36 // 6. Fetch user profile (subscription type, rate limit tier)
37 const profileInfo = await client.fetchProfileInfo(tokenResponse.access_token)
38
39 return this.formatTokens(tokenResponse, profileInfo.subscriptionType, ...)
40}

The key difference between automatic and manual flows lies in the redirect_uri:

src/services/oauth/client.ts
TypeScript
1authUrl.searchParams.append(
2 'redirect_uri',
3 isManual
4 ? getOauthConfig().MANUAL_REDIRECT_URL // Displays the auth code for the user to copy
5 : `http://localhost:${port}/callback`, // Local server captures automatically
6)

In the automatic flow, the OAuth provider redirects the user to http://localhost:{port}/callback?code=AUTH_CODE&state=STATE, and the local AuthCodeListener captures this request.

AuthCodeListener: Local Callback Server

AuthCodeListener is a temporary HTTP server whose lifecycle covers exactly one authorization flow:

src/services/oauth/auth-code-listener.ts
TypeScript
1export class AuthCodeListener {
2 private localServer: Server
3 private pendingResponse: ServerResponse | null = null // Deferred response for redirect
4
5 async start(port?: number): Promise<number> {
6 return new Promise((resolve, reject) => {
7 // Listen on an OS-assigned port to avoid port conflicts
8 this.localServer.listen(port ?? 0, 'localhost', () => {
9 const address = this.localServer.address() as AddressInfo
10 this.port = address.port
11 resolve(this.port)
12 })
13 })
14 }
15}

An important design detail: the server does not immediately respond to the /callback request. It extracts the authorization code first, then stores the ServerResponse in pendingResponse. This allows the outer OAuthService to decide whether to redirect to a success or error page after completing the token exchange:

src/services/oauth/auth-code-listener.ts
TypeScript
1handleSuccessRedirect(scopes: string[]): void {
2 if (!this.pendingResponse) return
3 const successUrl = shouldUseClaudeAIAuth(scopes)
4 ? getOauthConfig().CLAUDEAI_SUCCESS_URL
5 : getOauthConfig().CONSOLE_SUCCESS_URL
6 this.pendingResponse.writeHead(302, { Location: successUrl })
7 this.pendingResponse.end()
8 this.pendingResponse = null
9}

OAuth Scope System

Claude Code's OAuth scopes define a layered permission model:

src/constants/oauth.ts
TypeScript
1export const CLAUDE_AI_INFERENCE_SCOPE = 'user:inference' as const
2export const CLAUDE_AI_PROFILE_SCOPE = 'user:profile' as const
3const CONSOLE_SCOPE = 'org:create_api_key' as const
4
5// Console OAuth scopes - API Key creation
6export const CONSOLE_OAUTH_SCOPES = [
7 CONSOLE_SCOPE,
8 CLAUDE_AI_PROFILE_SCOPE,
9] as const
10
11// Claude.ai OAuth scopes - subscription users
12export const CLAUDE_AI_OAUTH_SCOPES = [
13 CLAUDE_AI_PROFILE_SCOPE,
14 CLAUDE_AI_INFERENCE_SCOPE,
15 'user:sessions:claude_code',
16 'user:mcp_servers',
17 'user:file_upload',
18] as const
19
20// Request the union of all scopes during login
21export const ALL_OAUTH_SCOPES = Array.from(
22 new Set([...CONSOLE_OAUTH_SCOPES, ...CLAUDE_AI_OAUTH_SCOPES]),
23)

The user:inference scope is the key indicator of whether a user is a Claude.ai subscriber (Pro/Max/Team/Enterprise). The shouldUseClaudeAIAuth() function checks this scope to determine the authentication path:

src/services/oauth/client.ts
TypeScript
1export function shouldUseClaudeAIAuth(scopes: string[] | undefined): boolean {
2 return Boolean(scopes?.includes(CLAUDE_AI_INFERENCE_SCOPE))
3}

Token Storage and Refresh

Token Read Path

getClaudeAIOAuthTokens() is the single entry point for reading OAuth tokens in the system, wrapped with memoize to avoid redundant Keychain reads:

src/utils/auth.ts
TypeScript
1export const getClaudeAIOAuthTokens = memoize((): OAuthTokens | null => {
2 if (isBareMode()) return null
3
4 // Priority 1: Environment variable (inference-only token, no refresh capability)
5 if (process.env.CLAUDE_CODE_OAUTH_TOKEN) {
6 return {
7 accessToken: process.env.CLAUDE_CODE_OAUTH_TOKEN,
8 refreshToken: null,
9 expiresAt: null,
10 scopes: ['user:inference'],
11 subscriptionType: null,
12 rateLimitTier: null,
13 }
14 }
15
16 // Priority 2: File descriptor (CCR / Claude Desktop)
17 const oauthTokenFromFd = getOAuthTokenFromFileDescriptor()
18 if (oauthTokenFromFd) {
19 return { accessToken: oauthTokenFromFd, refreshToken: null, ... }
20 }
21
22 // Priority 3: Secure storage (Keychain / file)
23 try {
24 const secureStorage = getSecureStorage()
25 const storageData = secureStorage.read()
26 const oauthData = storageData?.claudeAiOauth
27 if (!oauthData?.accessToken) return null
28 return oauthData
29 } catch (error) {
30 logError(error)
31 return null
32 }
33})

Note that tokens from environment variables and file descriptors have no refreshToken or expiresAt — they are "inference-only" short-lived tokens whose lifecycle is managed by an external system.

Token Expiration Detection

Expiration detection uses a 5-minute buffer to ensure refresh is triggered before the token actually expires:

src/services/oauth/client.ts
TypeScript
1export function isOAuthTokenExpired(expiresAt: number | null): boolean {
2 if (expiresAt === null) return false
3 const bufferTime = 5 * 60 * 1000 // 5 minutes
4 const now = Date.now()
5 return (now + bufferTime) >= expiresAt
6}

Multi-Process Safe Token Refresh

Token refresh is the most complex part of the entire authentication system. Consider this scenario: a user is running 3 claude processes simultaneously, and their tokens all expire at the same time. If all three try to refresh, only one refresh token is valid (the server rotates them), and the other two will fail.

The solution is file locking + double-checking:

src/utils/auth.ts
TypeScript
1async function checkAndRefreshOAuthTokenIfNeededImpl(
2 retryCount: number,
3 force: boolean,
4): Promise<boolean> {
5 const MAX_RETRIES = 5
6
7 // First check: is the cached token expired?
8 const tokens = getClaudeAIOAuthTokens()
9 if (!force && (!tokens?.refreshToken || !isOAuthTokenExpired(tokens.expiresAt))) {
10 return false
11 }
12
13 // Second check: async re-read (another process may have already refreshed)
14 getClaudeAIOAuthTokens.cache?.clear?.()
15 clearKeychainCache()
16 const freshTokens = await getClaudeAIOAuthTokensAsync()
17 if (!freshTokens?.refreshToken || !isOAuthTokenExpired(freshTokens.expiresAt)) {
18 return false // Another process already completed the refresh
19 }
20
21 // Acquire file lock
22 const claudeDir = getClaudeConfigHomeDir()
23 let release
24 try {
25 release = await lockfile.lock(claudeDir)
26 } catch (err) {
27 if ((err as { code?: string }).code === 'ELOCKED') {
28 if (retryCount < MAX_RETRIES) {
29 await sleep(1000 + Math.random() * 1000) // Random backoff
30 return checkAndRefreshOAuthTokenIfNeededImpl(retryCount + 1, force)
31 }
32 return false
33 }
34 }
35
36 try {
37 // Third check: verify again after acquiring the lock
38 const lockedTokens = await getClaudeAIOAuthTokensAsync()
39 if (!lockedTokens?.refreshToken || !isOAuthTokenExpired(lockedTokens.expiresAt)) {
40 return false // Another process completed refresh while we were acquiring the lock
41 }
42
43 // Actual refresh
44 const refreshedTokens = await refreshOAuthToken(lockedTokens.refreshToken, {
45 scopes: shouldUseClaudeAIAuth(lockedTokens.scopes) ? undefined : lockedTokens.scopes,
46 })
47 saveOAuthTokensIfNeeded(refreshedTokens)
48 return true
49 } finally {
50 await release()
51 }
52}
...

The essence of the triple-check pattern: the first check is the fast path (in-memory cache), the second check avoids unnecessary lock contention (async Keychain read), and the third check is the final confirmation after acquiring the lock. Random backoff (1000 + Math.random() * 1000) ensures multiple processes don't retry at the exact same moment.

Scope Expansion During Token Refresh

The scope handling during refresh has a noteworthy design:

src/services/oauth/client.ts
TypeScript
1const requestBody = {
2 grant_type: 'refresh_token',
3 refresh_token: refreshToken,
4 client_id: getOauthConfig().CLIENT_ID,
5 // The backend's refresh-token grant allows scope expansion
6 // so it's safe even if the token was issued before new scopes were added
7 scope: (requestedScopes?.length ? requestedScopes : CLAUDE_AI_OAUTH_SCOPES).join(' '),
8}

For Claude.ai subscription users, the current scopes are not passed during refresh — instead the default CLAUDE_AI_OAUTH_SCOPES are used. This allows expanding scopes (e.g., adding user:file_upload) without requiring the user to re-login. The backend controls which scopes can be acquired via refresh through an ALLOWED_SCOPE_EXPANSIONS allowlist.

Optimized Profile Information Fetching

Every token refresh could potentially be accompanied by a /api/oauth/profile call to fetch subscription type and rate limits. But at full deployment this call runs roughly 7M times per day. To address this, skip logic was introduced:

src/services/oauth/client.ts
TypeScript
1const haveProfileAlready =
2 config.oauthAccount?.billingType !== undefined &&
3 config.oauthAccount?.accountCreatedAt !== undefined &&
4 config.oauthAccount?.subscriptionCreatedAt !== undefined &&
5 existing?.subscriptionType != null &&
6 existing?.rateLimitTier != null
7
8const profileInfo = haveProfileAlready
9 ? null // Skip ~7M req/day
10 : await fetchProfileInfo(accessToken)

The comments in this code document a subtle race condition: in the CLAUDE_CODE_OAUTH_REFRESH_TOKEN re-login path, installOAuthTokens executes performLogout() after returning, which clears secure storage. If null is returned as subscriptionType at this point, saveOAuthTokensIfNeeded would permanently lose the paying user's subscription type. This is avoided by passing through the existing value (existing?.subscriptionType).

JWT in Bridge Mode

JWT Decoding

In Bridge mode, the server-issued worker JWT is used for session authentication. jwtUtils.ts provides JWT decoding without signature verification — because verification is the server's responsibility, and the client only needs to read the exp claim to schedule refreshes:

src/bridge/jwtUtils.ts
TypeScript
1export function decodeJwtPayload(token: string): unknown | null {
2 // Strip sk-ant-si- prefix (session-ingress token)
3 const jwt = token.startsWith('sk-ant-si-')
4 ? token.slice('sk-ant-si-'.length)
5 : token
6 const parts = jwt.split('.')
7 if (parts.length !== 3 || !parts[1]) return null
8 try {
9 return jsonParse(Buffer.from(parts[1], 'base64url').toString('utf8'))
10 } catch {
11 return null
12 }
13}

createTokenRefreshScheduler

This is the core component in Bridge mode — a generic token refresh scheduler used by both the standalone bridge and the REPL bridge:

src/bridge/jwtUtils.ts
TypeScript
1export function createTokenRefreshScheduler({
2 getAccessToken,
3 onRefresh,
4 label,
5 refreshBufferMs = TOKEN_REFRESH_BUFFER_MS, // Default 5 minutes
6}: {
7 getAccessToken: () => string | undefined | Promise<string | undefined>
8 onRefresh: (sessionId: string, oauthToken: string) => void
9 label: string
10 refreshBufferMs?: number
11}): {
12 schedule: (sessionId: string, token: string) => void
13 scheduleFromExpiresIn: (sessionId: string, expiresInSeconds: number) => void
14 cancel: (sessionId: string) => void
15 cancelAll: () => void
16}

The scheduler's core is the "generation counter" pattern, used to solve races between async refreshes and rescheduling:

src/bridge/jwtUtils.ts
TypeScript
1const timers = new Map<string, ReturnType<typeof setTimeout>>()
2const failureCounts = new Map<string, number>()
3const generations = new Map<string, number>()
4
5function nextGeneration(sessionId: string): number {
6 const gen = (generations.get(sessionId) ?? 0) + 1
7 generations.set(sessionId, gen)
8 return gen
9}

Every time schedule() or cancel() is called, the generation for the corresponding session is incremented. The async doRefresh() checks whether the generation still matches after completing — if it doesn't, the schedule has been superseded and the current refresh should be abandoned:

src/bridge/jwtUtils.ts
TypeScript
1async function doRefresh(sessionId: string, gen: number): Promise<void> {
2 let oauthToken: string | undefined
3 try {
4 oauthToken = await getAccessToken()
5 } catch (err) { ... }
6
7 // If the session was cancelled or rescheduled during the await, generation will have changed
8 if (generations.get(sessionId) !== gen) {
9 logForDebugging(`... stale (gen ${gen} vs ${generations.get(sessionId)}), skipping`)
10 return // Avoid orphaned timers
11 }
12
13 onRefresh(sessionId, oauthToken)
14
15 // Schedule subsequent refresh to maintain auth for long-lived sessions
16 const timer = setTimeout(doRefresh, FALLBACK_REFRESH_INTERVAL_MS, sessionId, gen)
17 timers.set(sessionId, timer)
18}

Usage in the REPL bridge:

src/bridge/remoteBridgeCore.ts
TypeScript
1const refresh = createTokenRefreshScheduler({
2 refreshBufferMs: cfg.token_refresh_buffer_ms,
3 getAccessToken: async () => {
4 // Unconditionally refresh OAuth then call /bridge
5 await checkAndRefreshOAuthTokenIfNeeded()
6 return getClaudeAIOAuthTokens()?.accessToken
7 },
8 onRefresh: async (sessionId, oauthToken) => {
9 // Each /bridge call bumps the epoch
10 // JWT-only exchange leaves the old epoch's heartbeat -> 409 within 20s
11 const bridge = await callBridgeEndpoint(sessionId, oauthToken)
12 // Rebuild transport with new JWT + new epoch
13 await rebuildTransport(bridge)
14 },
15 label: 'repl-v2',
16})

Failure Retry and Bailout

The scheduler implements a capped retry mechanism, with a maximum of 3 consecutive failures:

src/bridge/jwtUtils.ts
TypeScript
1const MAX_REFRESH_FAILURES = 3
2const REFRESH_RETRY_DELAY_MS = 60_000 // Retry after 1 minute
3
4// L185-205
5if (!oauthToken) {
6 const failures = (failureCounts.get(sessionId) ?? 0) + 1
7 failureCounts.set(sessionId, failures)
8 if (failures < MAX_REFRESH_FAILURES) {
9 const retryTimer = setTimeout(doRefresh, REFRESH_RETRY_DELAY_MS, sessionId, gen)
10 timers.set(sessionId, retryTimer)
11 }
12 return // After 3 failures, abandon the refresh chain
13}
14
15// Reset failure counter on success
16failureCounts.delete(sessionId)

/login and /logout Commands

/login Flow

The /login command renders a ConsoleOAuthFlow component that guides the user through OAuth authorization. After successful login, a series of reset and refresh operations are triggered:

src/commands/login/login.tsx
TypeScript
1export async function call(onDone, context): Promise<React.ReactNode> {
2 return <Login onDone={async success => {
3 context.onChangeAPIKey()
4 // Signature blocks are bound to the API Key — must be cleared after switching
5 context.setMessages(stripSignatureBlocks)
6
7 if (success) {
8 resetCostState() // Reset cost tracking
9 void refreshRemoteManagedSettings() // Refresh remote managed settings
10 void refreshPolicyLimits() // Refresh policy limits
11 resetUserCache() // Clear user cache
12 refreshGrowthBookAfterAuthChange() // Refresh feature flags
13 clearTrustedDeviceToken() // Clear old device token
14 void enrollTrustedDevice() // Enroll new device
15 resetBypassPermissionsCheck() // Reset permission checks
16 // Increment authVersion to trigger hooks that depend on auth to re-fetch data
17 context.setAppState(prev => ({
18 ...prev,
19 authVersion: prev.authVersion + 1,
20 }))
21 }
22 onDone(success ? 'Login successful' : 'Login interrupted')
23 }} />
24}

The authVersion increment is a clever React pattern — changing a number triggers all hooks that listen for auth changes (such as MCP server lists) to re-execute.

/logout Flow

Logout must execute cleanup operations in a specific order:

src/commands/logout/logout.tsx
TypeScript
1export async function performLogout({ clearOnboarding = false }): Promise<void> {
2 // 1. Flush telemetry data first (before clearing credentials, to prevent org data leakage)
3 const { flushTelemetry } = await import('../../utils/telemetry/instrumentation.js')
4 await flushTelemetry()
5
6 // 2. Remove API Key
7 await removeApiKey()
8
9 // 3. Clear all secure storage data
10 const secureStorage = getSecureStorage()
11 secureStorage.delete()
12
13 // 4. Clear authentication-related caches
14 await clearAuthRelatedCaches()
15
16 // 5. Clear OAuth account info from config
17 saveGlobalConfig(current => {
18 const updated = { ...current }
19 if (clearOnboarding) {
20 updated.hasCompletedOnboarding = false
21 updated.subscriptionNoticeCount = 0
22 updated.hasAvailableSubscription = false
23 }
24 updated.oauthAccount = undefined
25 return updated
26 })
27}

The critical ordering: telemetry must be flushed before credentials are cleared, otherwise subsequent telemetry events would lose org context. The lazy import for flushTelemetry is another performance optimization — the OpenTelemetry package is about 1.1MB and isn't loaded at startup.

clearAuthRelatedCaches clears all authentication-related in-memory caches:

src/commands/logout/logout.tsx
TypeScript
1export async function clearAuthRelatedCaches(): Promise<void> {
2 getClaudeAIOAuthTokens.cache?.clear?.() // OAuth token cache
3 clearTrustedDeviceTokenCache() // Trusted device token
4 clearBetasCaches() // Beta feature flags
5 clearToolSchemaCache() // Tool schema cache
6 resetUserCache() // User data cache
7 refreshGrowthBookAfterAuthChange() // GrowthBook refresh
8 getGroveNoticeConfig.cache?.clear?.() // Grove config
9 getGroveSettings.cache?.clear?.()
10 await clearRemoteManagedSettingsCache() // Remote managed settings
11 await clearPolicyLimitsCache() // Policy limits
12}

Security Considerations

Credential Passing Security

On macOS, credentials are passed via security -i (stdin) rather than command-line arguments, preventing endpoint detection and response (EDR) software from logging credentials. Fallback to argv only occurs when the payload exceeds the stdin buffer limit.

CSRF Protection

The state parameter in the OAuth flow is used not only for standard CSRF protection but also to correlate automatic and manual flows:

src/services/oauth/auth-code-listener.ts
TypeScript
1private validateAndRespond(authCode, state, res): void {
2 if (!authCode) {
3 res.writeHead(400)
4 this.reject(new Error('No authorization code received'))
5 return
6 }
7 if (state !== this.expectedState) {
8 res.writeHead(400)
9 res.end('Invalid state parameter')
10 this.reject(new Error('Invalid state parameter'))
11 return
12 }
13 this.pendingResponse = res
14 this.resolve(authCode)
15}

Keychain Lock Detection

In SSH sessions, the macOS Keychain may be in a locked state. The system detects this condition and displays a prompt to the user in the UI:

src/utils/secureStorage/macOsKeychainStorage.ts
TypeScript
1export function isMacOsKeychainLocked(): boolean {
2 if (keychainLockedCache !== undefined) return keychainLockedCache
3 if (process.platform !== 'darwin') return false
4
5 try {
6 const result = execaSync('security', ['show-keychain-info'], { reject: false })
7 keychainLockedCache = result.exitCode === 36 // exit code 36 = keychain locked
8 } catch {
9 keychainLockedCache = false
10 }
11 return keychainLockedCache
12}

The result is cached because the Keychain lock state doesn't change during a CLI session, and execaSync takes about 27ms each time — in virtual scroll message remount scenarios, every message would re-trigger the detection.

Multi-Layer Token Storage Protection

Token storage forms a security gradient:

  1. macOS Keychain (highest security): OS-level encrypted storage requiring user password to unlock
  2. Plaintext file fallback (Linux/Keychain unavailable): Stored in ~/.claude/ directory, protected by file permissions
  3. Environment variable tokens (externally managed): Not stored; the caller is responsible for security

Portable Patterns

Claude Code's authentication system supports several "portable" scenarios:

Host-Container Sharing

When the .claude directory is shared between host and container, the container typically cannot access the macOS Keychain. createFallbackStorage handles this migration — deleting the file storage copy on first successful Keychain write, and vice versa:

src/utils/secureStorage/fallbackStorage.ts
TypeScript
1if (result.success) {
2 // Delete secondary on first migration to primary
3 // Preserves credentials when host and container share .claude
4 if (primaryDataBefore === null) {
5 secondary.delete()
6 }
7 return result
8}

Environment Isolation

Different CLAUDE_CONFIG_DIR values map to different Keychain service names:

src/utils/secureStorage/macOsKeychainHelpers.ts
TypeScript
1export function getMacOsKeychainStorageServiceName(serviceSuffix = ''): string {
2 const configDir = getClaudeConfigHomeDir()
3 const isDefaultDir = !process.env.CLAUDE_CONFIG_DIR
4 const dirHash = isDefaultDir
5 ? ''
6 : `-${createHash('sha256').update(configDir).digest('hex').substring(0, 8)}`
7 return `Claude Code${getOauthConfig().OAUTH_FILE_SUFFIX}${serviceSuffix}${dirHash}`
8}

This ensures that credentials from different config directories (such as staging, local, custom OAuth URL) don't interfere with each other. OAUTH_FILE_SUFFIX is empty in production, -staging-oauth for staging, and -local-oauth for local.

SSH Remote Authentication

When claude ssh starts a remote session, API calls are proxied through a Unix Socket tunnel. The remote end doesn't directly hold credentials — instead, it points to a local auth-injecting proxy via the ANTHROPIC_UNIX_SOCKET environment variable. In this scenario, CLAUDE_CODE_OAUTH_TOKEN serves as a placeholder, only to inform the remote end that the current user is an OAuth subscriber so the correct beta header is sent.

src/utils/auth.ts
TypeScript
1if (process.env.ANTHROPIC_UNIX_SOCKET) {
2 return !!process.env.CLAUDE_CODE_OAUTH_TOKEN
3}

Summary

Claude Code's authentication architecture demonstrates the balance a production-grade system strikes between security, performance, and usability:

  • Unified multi-source handling: getAuthTokenSource() and getClaudeAIOAuthTokens() abstract 6+ credential sources into a unified token interface
  • Platform-adaptive storage: The SecureStorage interface + FallbackStorage pattern implements a "Keychain first, file fallback" progressive enhancement
  • Cold start optimization: startKeychainPrefetch() parallelizes ~65ms of sequential Keychain reads into the module loading phase, achieving near-zero cost
  • Multi-process safety: Triple-check + file lock + random backoff ensures multiple Claude Code instances don't race to refresh the same token
  • Bridge JWT scheduling: The generation counter pattern elegantly solves race conditions in async refresh
  • Defense in depth: stdin credential passing, PKCE authorization code protection, Keychain lock detection, flushing telemetry before clearing credentials during logout

Every layer has carefully designed fallback and error recovery strategies. In particular, stale-while-error (continuing to use old data when Keychain reads fail) and scope expansion on refresh (automatically acquiring new scopes during refresh) reflect a mature system's thorough consideration of edge cases.