Claude OAuth¶
How Claude Code authenticates via OAuth and how coqu implements it in headless (Docker/SSH) environments.
Overview¶
Claude Code uses OAuth 2.0 with PKCE (Proof Key for Code Exchange) to authenticate users against claude.ai. There are two flows depending on environment:
- Local — CLI opens a browser, user authorizes, browser redirects to
localhost:PORT/callback, CLI exchanges the code automatically. - Headless (platform code flow) — no browser available on the server. User gets a URL to open manually, authorizes, sees a code on a platform page, pastes it back. The server exchanges the code itself.
coqu uses the local flow when a desktop environment is detected and the platform code flow in headless environments (Docker, SSH, or when COQU_FORCE_HEADLESS=1 is set).
OAuth endpoints¶
| Endpoint | URL |
|---|---|
| Authorization | https://claude.ai/oauth/authorize |
| Token exchange | https://platform.claude.com/v1/oauth/token |
| Platform redirect (headless) | https://platform.claude.com/oauth/code/callback |
Constants¶
| Name | Value |
|---|---|
| Client ID | 9d1c250a-e61b-44d9-88ed-5944d1962f5e |
| Scopes | user:profile user:inference user:sessions:claude_code user:mcp_servers |
These are from the Claude CLI source and are shared across all Claude Code integrations.
Platform code flow (headless)¶
This is the flow coqu uses in Docker/SSH environments. We perform the full PKCE OAuth flow server-side — no CLI claude auth login process is spawned.
Sequence¶
User coqu API claude.ai platform.claude.com
│ │ │ │
│── Login ───►│ │ │
│ │── generate PKCE (verifier, challenge, state)
│◄── URL ─────│ │ │
│ │ │
│── open URL ────────────────►│ │
│ authorize │ │
│◄───────── redirect ─────────┼──────────────────►│
│ │ │ ?code=X&state=Y │
│◄────────── page shows code ─┼────────────────────│
│ │ │
│── paste code ──►│ │ │
│ │── POST /v1/oauth/token ────────►│
│ │ (code, verifier, state) │
│ │◄── access_token, refresh_token ─│
│ │── write ~/.claude/.credentials.json
│◄── success ─────│ │ │
Step 1: Generate PKCE parameters¶
code_verifier = base64url(random 32 bytes)
code_challenge = base64url(SHA-256(code_verifier))
state = base64url(random 32 bytes)
The code_verifier and state are stored in memory (keyed by agent ID) for use during code exchange. They expire after 5 minutes.
Step 2: Build authorization URL¶
https://claude.ai/oauth/authorize
?code=true
&client_id=9d1c250a-e61b-44d9-88ed-5944d1962f5e
&response_type=code
&redirect_uri=https://platform.claude.com/oauth/code/callback
&scope=user:profile user:inference user:sessions:claude_code user:mcp_servers
&code_challenge=<base64url>
&code_challenge_method=S256
&state=<base64url>
The code=true parameter tells the auth server to use the manual code flow — after authorization, the user is redirected to the platform callback page which displays the code visually instead of passing it to a localhost server.
The redirect_uri must be https://platform.claude.com/oauth/code/callback — this is what the token endpoint validates against. Using any other redirect URI (e.g. localhost) will cause invalid_grant.
Step 3: User authorizes and copies code¶
After authorizing on claude.ai, the browser redirects to:
The platform page displays the authorization code for the user to copy.
Important: The displayed code is 92 characters in the format <auth_code>#<fragment>. Only the part before # (48 characters) is the actual authorization code. The fragment after # must be stripped before exchange.
Step 4: Exchange code for tokens¶
POST https://platform.claude.com/v1/oauth/token
Content-Type: application/x-www-form-urlencoded
grant_type=authorization_code
&code=<auth_code (without # fragment)>
&redirect_uri=https://platform.claude.com/oauth/code/callback
&client_id=9d1c250a-e61b-44d9-88ed-5944d1962f5e
&code_verifier=<base64url>
&state=<base64url>
Content-Type must be application/x-www-form-urlencoded, not JSON. The token endpoint returns 400 invalid_grant if JSON is used.
Response (200):
{
"token_type": "Bearer",
"access_token": "sk-ant-oat01-...",
"expires_in": 28800,
"refresh_token": "sk-ant-ort01-..."
}
expires_inis 28800 seconds (8 hours)- Access tokens have prefix
sk-ant-oat01- - Refresh tokens have prefix
sk-ant-ort01-
Step 5: Write credentials¶
Tokens are written to ~/.claude/.credentials.json with permissions 0600:
{
"claudeAiOauth": {
"accessToken": "sk-ant-oat01-...",
"refreshToken": "sk-ant-ort01-...",
"expiresAt": "2026-02-23T05:40:22.000Z",
"scopes": ["user:profile", "user:inference", "user:sessions:claude_code", "user:mcp_servers"],
"subscriptionType": null,
"rateLimitTier": null
}
}
The CLI reads this file on every invocation. No process restart is needed — the next claude command picks up the new credentials automatically.
Local flow¶
In non-headless environments, coqu spawns claude auth login as a child process. The CLI:
- Starts a localhost HTTP server on a random port
- Opens the browser with an authorization URL where
redirect_uri=http://localhost:PORT/callback - After authorization, the browser redirects to localhost, the CLI exchanges the code and writes credentials
coqu monitors the CLI's stdout for the authorization URL and returns it to the frontend. The flow is fully automatic — no code pasting needed.
Gotchas discovered during implementation¶
| Issue | Detail |
|---|---|
Code contains # fragment |
The platform page shows a 92-char string: <code>#<fragment>. Only the 48 chars before # are the auth code. |
| Token endpoint rejects JSON | Must use application/x-www-form-urlencoded. JSON body returns invalid_grant. |
| redirect_uri must match exactly | The code is bound to the redirect_uri used in the authorization request. Cannot mix localhost and platform redirect URIs. |
| Codes are single-use | An authorization code can only be exchanged once. Retrying with the same code returns invalid_grant. |
| PKCE verifier is mandatory | The auth server enforces PKCE. Omitting code_verifier in the token request fails. |
No code_verifier access from CLI |
When the CLI spawns its own OAuth flow, the code_verifier is generated internally and inaccessible. This is why the headless flow must do the entire PKCE flow independently. |
Headless detection¶
const isHeadless = !!process.env.COQU_FORCE_HEADLESS
|| (!process.env.DISPLAY && process.platform === "linux");
COQU_FORCE_HEADLESS=1— forces headless mode on any platform (useful for testing on macOS)- No
DISPLAY+ Linux — typical Docker or SSH session
Files¶
| File | Role |
|---|---|
packages/api/src/oauth.ts |
PKCE generation, token exchange, credential writing |
packages/api/src/index.ts |
Login and code-submit endpoints (routes the two flows) |
packages/api/src/auth.ts |
Auth status check via claude auth status |
packages/web/src/pages/AgentDetailPage.tsx |
Login UI (shows URL + code input for headless) |