Claude Code는 PKCE를 사용하는 OAuth 2.0 Authorization Code flow (Proof Key for Code Exchange)를 구현해 Anthropic Console 또는 Claude.ai를 상대로 사용자를 인증합니다. 바이너리 안에는 client secret이 들어 있지 않습니다. 대신 암호학적 verifier/challenge 쌍을 사용해 각 인가 요청을 위조할 수 없게 만듭니다.
구현은 세 계층에 걸쳐 있습니다:
services/oauth/crypto.ts: PKCE verifier, S256 challenge, CSRF state를 생성합니다.services/oauth/client.ts: 인증 URL을 만들고, 코드를 토큰으로 교환하고, 토큰을 갱신하고, 사용자 프로필을 가져옵니다.services/oauth/index.ts (OAuthService): 리스너를 연결하고, 자동 및 수동 흐름을 경쟁시키고, 최종 토큰 객체를 포맷합니다.platform.claude.com/oauth/authorize)은 API key 기반 워크플로에 사용됩니다. Claude.ai (claude.com/cai/oauth/authorize)는 Pro, Max, Team, Enterprise 구독자가 자신의 claude.ai 계정으로 직접 인증할 때 사용됩니다.
PKCE는 각 흐름을 시작한 프로세스만 알고 있는 일회성 비밀값에 묶어서, authorization code 가로채기를 막습니다.
import { createHash, randomBytes } from 'crypto' function base64URLEncode(buffer: Buffer): string { return buffer .toString('base64') .replace(/\+/g, '-') .replace(/\//g, '_') .replace(/=/g, '') // RFC 4648 §5, URL 안전 형식, 패딩 없음 } export function generateCodeVerifier(): string { return base64URLEncode(randomBytes(32)) // 256비트 엔트로피 } export function generateCodeChallenge(verifier: string): string { const hash = createHash('sha256') hash.update(verifier) return base64URLEncode(hash.digest()) // S256 방식 } export function generateState(): string { return base64URLEncode(randomBytes(32)) // CSRF 보호 } crypto.ts
| 값 | 생성 방법 | 서버 전송 | 목적 |
|---|---|---|---|
codeVerifier |
무작위 32바이트, base64url | 토큰 교환 때만 | 이 프로세스가 흐름을 시작했음을 증명 |
codeChallenge |
SHA-256(verifier), base64url | 인가 URL 파라미터 | 서버가 저장해 두었다가 나중에 verifier를 검증 |
state |
무작위 32바이트, base64url | 인증 URL + callback URL | CSRF 방지, callback이 정확히 같은 state를 돌려줘야 함 |
codeVerifier는 서버가 시작되기 전에 OAuthService의 constructor 내부에서 생성됩니다. 즉, 로그인 시도가 살아 있는 동안에만 메모리에 존재합니다.
아래 다이어그램은 완전한 자동 OAuth 흐름을 보여줍니다. 수동 폴백은 "브라우저 열기" 단계에서 갈라지며, 다음 섹션에서 설명합니다.
두 흐름은 동시에 시작됩니다. 먼저 authorization code를 전달하는 쪽이 승리합니다.
핵심은 OAuthService가 하나의 Promise를 두 resolver에 경쟁시킨다는 점입니다.
AuthCodeListener가 OS가 할당한 포트에서 HTTP 서버를 시작합니다redirect_uri=localhost:PORT/callback가 포함된 automaticFlowUrl을 엽니다state를 검증하고 auth code Promise를 resolve합니다platform.claude.com/oauth/code/success로 성공 리다이렉트를 받습니다redirect_uri=platform.claude.com/oauth/code/callback가 포함된 manualFlowUrl을 표시합니다handleManualAuthCodeInput()이 저장된 resolver를 직접 호출합니다localhost에 접근할 수 없는 SSH 세션이나 환경에서 사용됩니다async startOAuthFlow( authURLHandler: (url: string, automaticUrl?: string) => Promise<void>, options?: { skipBrowserOpen?: boolean; inferenceOnly?: boolean; ... } ): Promise<OAuthTokens> { // 1. localhost callback 서버 시작 this.authCodeListener = new AuthCodeListener() this.port = await this.authCodeListener.start() // 2. 같은 PKCE 값으로 두 URL 구성 const manualFlowUrl = client.buildAuthUrl({ ...opts, isManual: true }) const automaticFlowUrl = client.buildAuthUrl({ ...opts, isManual: false }) // 3. 경쟁: 자동(localhost) 대 수동(붙여 넣기) const authorizationCode = await this.waitForAuthorizationCode(state, async () => { if (options?.skipBrowserOpen) { await authURLHandler(manualFlowUrl, automaticFlowUrl) // SDK 모드 } else { await authURLHandler(manualFlowUrl) // 사용자에게 수동 URL 표시 await openBrowser(automaticFlowUrl) // 자동 흐름 시도 } }) // 4. 어느 흐름이 이겼는가? const isAutomatic = this.authCodeListener?.hasPendingResponse() ?? false // 5. 코드를 토큰으로 교환 const tokenResponse = await client.exchangeCodeForTokens( authorizationCode, state, this.codeVerifier, this.port!, !isAutomatic // auto가 이기지 못했다면 isManual = true ) // 6. 프로필 API에서 구독 및 rate-limit tier 조회 const profileInfo = await client.fetchProfileInfo(tokenResponse.access_token) // 7. 브라우저를 성공 페이지로 리다이렉트한 뒤 정리 if (isAutomatic) this.authCodeListener?.handleSuccessRedirect(scopes) return this.formatTokens(tokenResponse, profileInfo.subscriptionType, ...) } services/oauth/index.ts
claude_authenticate)이 로그인을 주도할 때는 skipBrowserOpen: true를 설정합니다. 두 URL 모두 authURLHandler를 통해 호출자에게 전달되며, 어디서 열지는 Claude Code가 아니라 SDK 클라이언트가 결정합니다.
AuthCodeListener는 OAuth 제공자의 리다이렉트를 받아 대기 중인 Promise에 authorization code를 넘겨주는 일만 하는 최소한의 Node.js HTTP 서버입니다.
포트 0으로 listen하면 OS가 빈 포트를 골라 줍니다. 그래서 "port already in use" 류의 오류를 아예 피할 수 있습니다. 선택된 포트는 인증 서버가 callback에 사용하는 redirect_uri 안에 포함됩니다.
callback이 도착하면 validateAndRespond()는 resolve하기 전에 state === expectedState인지 확인합니다. 값이 다르면 HTTP 400을 반환하고 promise를 reject해서 CSRF를 막습니다.
서버는 auth code promise를 resolve하기 전에 브라우저의 ServerResponse 객체를 pendingResponse에 저장합니다. 토큰 교환이 성공하면 handleSuccessRedirect()가 성공 페이지로의 302 응답으로 브라우저 요청을 마무리합니다. 덕분에 브라우저 탭이 멈춘 채 남지 않습니다.
응답이 아직 대기 중인 상태에서 close()가 호출되면, 예를 들어 토큰 교환이 실패한 경우에도 서버는 먼저 handleErrorRedirect()를 호출해 브라우저가 항상 응답을 받도록 보장합니다.
private validateAndRespond( authCode: string | undefined, state: string | undefined, res: ServerResponse, ): void { if (!authCode) { res.writeHead(400) res.end('Authorization code not found') this.reject(new Error('No authorization code received')) return } if (state !== this.expectedState) { res.writeHead(400) res.end('Invalid state parameter') this.reject(new Error('Invalid state parameter')) return } // 나중에 리다이렉트할 수 있도록 response를 저장, 브라우저가 멈추지 않게 함 this.pendingResponse = res this.resolve(authCode) } auth-code-listener.ts
authorization code를 확보하면, 토큰 엔드포인트로 POST를 보내 access token과 refresh token으로 교환합니다.
const requestBody = { grant_type: 'authorization_code', code: authorizationCode, redirect_uri: useManualRedirect ? getOauthConfig().MANUAL_REDIRECT_URL // https://platform.claude.com/oauth/code/callback : `http://localhost:${port}/callback`, // auth URL에 들어간 값과 정확히 일치해야 함 client_id: getOauthConfig().CLIENT_ID, // '9d1c250a-e61b-44d9-88ed-5944d1962f5e' code_verifier: codeVerifier, // 우리가 이 흐름을 시작했음을 증명 state, } // POST https://platform.claude.com/v1/oauth/token const response = await axios.post(TOKEN_URL, requestBody, { headers: { 'Content-Type': 'application/json' }, timeout: 15000, }) client.ts
토큰 요청의 redirect_uri는 인가 요청에서 사용한 값과 정확히 같아야 합니다. 이 제약은 서버 측에서 강제되며, 재사용 공격을 막는 또 하나의 장치입니다.
inferenceOnly: true일 때는 user:inference scope만 요청합니다. 이는 전체 scope 세트가 과한 SDK 프로그래매틱 접근을 위해 설계된 장기 수명 토큰입니다.
Scope는 발급된 access token이 무엇을 할 수 있는지 결정합니다. Claude Code는 로그인 시 Console과 Claude.ai의 scope 합집합을 요청해서, 하나의 토큰으로 두 경로를 모두 처리할 수 있게 합니다.
ALL_OAUTH_SCOPES)| 스코프 | 용도 |
|---|---|
org:create_api_key | Console 경로, 조직용 영구 API key 생성 |
user:profile | /api/oauth/profile에서 구독 유형, rate-limit tier, 계정 및 조직 정보 조회 |
user:inference | Claude.ai 경로, 추론 요청을 Claude.ai 구독을 통해 직접 라우팅 |
user:sessions:claude_code | Claude Code 클라이언트 전용 세션 관리 |
user:mcp_servers | 계정에 연결된 MCP 서버 접근 및 설정 |
user:file_upload | 처리를 위해 Anthropic 인프라로 파일 업로드 |
shouldUseClaudeAIAuth(scopes) 함수는 user:inference가 포함되어 있는지 확인합니다.
포함되어 있으면 추론 호출은 Claude.ai 인프라를 통해 라우팅되고, 그렇지 않으면 Console API key 경로가 사용됩니다.
토큰 교환이 끝나자마자 Claude Code는 사용자의 프로필을 가져와 구독 유형과 rate-limit tier를 판단합니다. 이 프로필 정보는 UI 선택, 모델 사용 가능 여부, 기능 플래그에 영향을 줍니다.
export async function fetchProfileInfo(accessToken: string) { const profile = await getOauthProfileFromOauthToken(accessToken) const orgType = profile?.organization?.organization_type let subscriptionType: SubscriptionType | null = null switch (orgType) { case 'claude_max': subscriptionType = 'max'; break case 'claude_pro': subscriptionType = 'pro'; break case 'claude_enterprise': subscriptionType = 'enterprise'; break case 'claude_team': subscriptionType = 'team'; break default: subscriptionType = null } return { subscriptionType, rateLimitTier: profile?.organization?.rate_limit_tier ?? null, hasExtraUsageEnabled: profile?.organization?.has_extra_usage_enabled ?? null, billingType: profile?.organization?.billing_type ?? null, displayName: profile?.account?.display_name, accountCreatedAt: profile?.account?.created_at, subscriptionCreatedAt: profile?.organization?.subscription_created_at, rawProfile: profile, } } client.ts
billingType, accountCreatedAt, subscriptionCreatedAt가 있고, secure storage에도 null이 아닌 subscriptionType와 rateLimitTier가 있으면 프로필 엔드포인트 호출을 완전히 건너뜁니다. 이 최적화는 전체 fleet 기준으로 하루 약 700만 건의 요청을 줄입니다.
토큰은 평문 config 파일이 아니라 플랫폼별 secure storage에 저장됩니다. 어떤 저장 계층을 쓸지는 런타임에 getSecureStorage()가 선택합니다.
| 플랫폼 | 기본 저장소 | 폴백 |
|---|---|---|
| macOS | macOsKeychainStorage, macOS security CLI (add-generic-password / find-generic-password) 사용 |
plainTextStorage, ~/.claude/ 안의 암호화된 JSON |
| Linux | plainTextStorage (libsecret 지원 예정) |
— |
| Windows | plainTextStorage |
— |
자격 증명을 저장할 때 macOS security 명령을 호출합니다. 여기에는 눈에 띄는 보안 엔지니어링 결정이 두 가지 있습니다:
-X 플래그). 이렇게 하면 셸 quoting 문제를 피할 수 있고, 더 중요하게는 CrowdStrike 같은 프로세스 모니터가 ps 출력이나 시스템 호출 로그에서 원시 토큰 값을 보지 못하게 합니다.
security -i): payload가 4032바이트 이내에 들어오면 (4096 - 64 여유분), 명령행 인자 대신 stdin으로 전달합니다. 덕분에 토큰이 프로세스 인자 목록에 나타나지 않습니다. payload가 한도를 넘으면 debug 경고와 함께 argv 폴백을 사용합니다.
security 서브프로세스가 일시적으로 실패하면, 사용자에게 "Not logged in" 오류를 보여주는 대신 마지막으로 정상적이었던 값을 캐시에서 제공합니다.
export async function installOAuthTokens(tokens: OAuthTokens): Promise<void> { // 1. 먼저 이전 상태를 삭제 (keychain 정리, 캐시 초기화) await performLogout({ clearOnboarding: false }) // 2. 계정 정보를 global config에 저장 (민감하지 않은 JSON) const profile = tokens.profile ?? await getOauthProfileFromOauthToken(tokens.accessToken) if (profile) { storeOAuthAccountInfo({ accountUuid, emailAddress, organizationUuid, ... }) } // 3. 토큰을 secure storage에 저장 (macOS에서는 keychain) const storageResult = saveOAuthTokensIfNeeded(tokens) clearOAuthTokenCache() // 4. 역할 조회 (org/workspace role), 중요하지 않으므로 실패 허용 await fetchAndStoreUserRoles(tokens.accessToken).catch(logForDebugging) // 5. Console 경로, 토큰을 통해 영구 API key 생성 if (!shouldUseClaudeAIAuth(tokens.scopes)) { await createAndStoreApiKey(tokens.accessToken) } } cli/handlers/auth.ts
Access token은 만료됩니다. Claude Code는 만료 5분 전 버퍼 구간을 두고 미리 갱신합니다. 이 갱신 흐름은 사용자가 거의 의식하지 못하도록 설계되어 있습니다.
export function isOAuthTokenExpired(expiresAt: number | null): boolean { if (expiresAt === null) return false const bufferTime = 5 * 60 * 1000 // 5분 일찍 const expiresWithBuffer = Date.now() + bufferTime return expiresWithBuffer >= expiresAt } client.ts
export async function refreshOAuthToken( refreshToken: string, { scopes: requestedScopes }: { scopes?: string[] } = {}, ): Promise<OAuthTokens> { const requestBody = { grant_type: 'refresh_token', refresh_token: refreshToken, client_id: getOauthConfig().CLIENT_ID, // 백엔드는 refresh 시 scope 확장을 허용함 (ALLOWED_SCOPE_EXPANSIONS) scope: (requestedScopes?.length ? requestedScopes : CLAUDE_AI_OAUTH_SCOPES).join(' '), } // 이미 모든 필드가 캐시에 있으면 프로필 조회 생략 const haveProfileAlready = config.oauthAccount?.billingType !== undefined && config.oauthAccount?.accountCreatedAt !== undefined && existing?.subscriptionType != null // secure storage도 함께 확인해야 함 const profileInfo = haveProfileAlready ? null : await fetchProfileInfo(accessToken) return { accessToken, refreshToken: newRefreshToken, // 서버가 refresh token을 회전시킬 수 있음 expiresAt: Date.now() + expiresIn * 1000, scopes, subscriptionType: profileInfo?.subscriptionType ?? existing?.subscriptionType ?? null, rateLimitTier: profileInfo?.rateLimitTier ?? existing?.rateLimitTier ?? null, } } client.ts
CLAUDE_CODE_OAUTH_REFRESH_TOKEN이 설정되면 Claude Code는 이를 사용해 새 토큰 교환을 수행합니다. 여기서 중요한 미묘한 점은 installOAuthTokens가 refresh가 반환된 뒤에 performLogout()를 호출한다는 것입니다. 만약 refreshOAuthToken이 프로필 필드 누락을 보고 subscriptionType: null을 반환하면, 이미 logout으로 지워진 뒤이기 때문에 이후 갱신에서 구독 유형을 영구히 잃게 됩니다. 해결책은 logout이 지우기 전에 secure storage의 캐시 값을 그대로 전달하는 것입니다.
로그아웃은 토큰 하나를 지우는 것보다 큽니다. 현재 신원에 의존하는 모든 상태 계층을 함께 정리해야 합니다.
export async function performLogout({ clearOnboarding = false }): Promise<void> { // 1. 자격 증명을 지우기 전에 telemetry를 먼저 flush // 계정이 지워진 뒤에 조직 귀속 이벤트가 전송되는 일을 막음 const { flushTelemetry } = await import('../../utils/telemetry/instrumentation.js') await flushTelemetry() await removeApiKey() // 2. secure storage 삭제 (keychain의 토큰 포함) const secureStorage = getSecureStorage() secureStorage.delete() // 3. 인증에 의존하는 모든 메모리 캐시 정리 await clearAuthRelatedCaches() // 4. global config 업데이트 saveGlobalConfig(current => ({ ...current, oauthAccount: undefined, // 계정 정보 제거 ...(clearOnboarding && { hasCompletedOnboarding: false, subscriptionNoticeCount: 0, hasAvailableSubscription: false, }), })) } commands/logout/logout.ts
getClaudeAIOAuthTokens.cache?.clear())
buildAuthUrl()은 필요한 모든 OAuth + PKCE 파라미터와 선택적 힌트를 모아 authorization URL을 구성합니다.
export function buildAuthUrl({ codeChallenge, state, port, isManual, loginWithClaudeAi, inferenceOnly, orgUUID, loginHint, loginMethod }) { // 계정 유형에 따라 인증 서버 선택 const authUrlBase = loginWithClaudeAi ? 'https://claude.com/cai/oauth/authorize' // 307으로 claude.ai로 이동 : 'https://platform.claude.com/oauth/authorize' const authUrl = new URL(authUrlBase) authUrl.searchParams.append('code', 'true') // Claude Max 업셀 표시 authUrl.searchParams.append('client_id', CLIENT_ID) authUrl.searchParams.append('response_type', 'code') authUrl.searchParams.append('redirect_uri', isManual ? 'https://platform.claude.com/oauth/code/callback' : `http://localhost:${port}/callback`) authUrl.searchParams.append('scope', scopesToUse.join(' ')) authUrl.searchParams.append('code_challenge', codeChallenge) authUrl.searchParams.append('code_challenge_method', 'S256') authUrl.searchParams.append('state', state) // 선택 사항: 로그인 폼 미리 채우기 (표준 OIDC) if (loginHint) authUrl.searchParams.append('login_hint', loginHint) // 선택 사항: 특정 로그인 방식 요청 if (loginMethod) authUrl.searchParams.append('login_method', loginMethod) // 선택 사항: 특정 org 지정 if (orgUUID) authUrl.searchParams.append('orgUUID', orgUUID) return authUrl.toString() } client.ts
?code=true 파라미터는 로그인 페이지에 Claude Max 구독 업셀을 표시하라고 지시하는 Claude 전용 플래그입니다. 표준 OAuth 파라미터는 아닙니다.
미국 연방 및 FedRAMP 배포 환경(FedStart)에서는 CLAUDE_CODE_CUSTOM_OAUTH_URL 환경 변수를 통해 모든 OAuth 엔드포인트를 승인된 base URL로 리디렉션할 수 있습니다.
const ALLOWED_OAUTH_BASE_URLS = [ 'https://beacon.claude-ai.staging.ant.dev', 'https://claude.fedstart.com', 'https://claude-staging.fedstart.com', ] const oauthBaseUrl = process.env.CLAUDE_CODE_CUSTOM_OAUTH_URL if (oauthBaseUrl) { const base = oauthBaseUrl.replace(/\/$/, '') if (!ALLOWED_OAUTH_BASE_URLS.includes(base)) { throw new Error('CLAUDE_CODE_CUSTOM_OAUTH_URL is not an approved endpoint.') } // 모든 OAuth URL을 FedStart 배포를 가리키도록 재설정 config = { ...config, BASE_API_URL: base, CONSOLE_AUTHORIZE_URL: `${base}/oauth/authorize`, ... } } constants/oauth.ts
엄격한 허용 목록 검사는 이 override가 OAuth 토큰을 임의의 서버로 보내는 데 악용되지 못하게 막습니다. 즉, 자격 증명 유출 공격을 차단합니다.
security -i (stdin)를 통해 hex 형태로 저장합니다. stale-while-error 캐시는 일시적인 서브프로세스 실패 때문에 사용자가 로그아웃되지 않게 막아 줍니다./api/oauth/profile 호출을 건너뜁니다. 이 최적화는 전체 fleet 기준 하루 수백만 건의 API 호출을 없앱니다.user:inference scope가 있으면 Claude.ai 구독자이며, 추론은 Claude.ai 인프라를 통해 직접 처리됩니다. 이 scope가 없으면 Console 경로를 쓰고, 로그인 후 API key를 만들어 모든 요청에 사용합니다.ALLOWED_SCOPE_EXPANSIONS 덕분에 refresh grant는 최초 인가 때 받은 범위를 넘어선 scope를 포함할 수 있습니다. 그래서 사용자가 처음 로그인한 뒤 추가된 새 scope도 재로그인 없이 다음 토큰 갱신 때 반영할 수 있습니다.code_verifier가 포함되지만, 인가 요청에는 code_challenge만 포함될까요?localhost:PORT/callback에 접근할 수 없으면 어떻게 될까요?shouldUseClaudeAIAuth()가 true를 반환하게 만들까요?performLogout()에서는 자격 증명을 지우기 전에 telemetry를 flush할까요?/api/oauth/profile 호출은 언제 생략될까요?