Stored /login credentials silently beat a valid CLAUDE_CODE_OAUTH_TOKEN (contradicts documented auth precedence)

Status Open
Reported on v2.1.173
Maintainer reply None cached
Activity 5 comments · opened Jun 22, 2026

Stored /login credentials silently beat a valid CLAUDE_CODE_OAUTH_TOKEN

Summary

When the macOS Keychain holds /login credentials, Claude Code authenticates with them even when the environment sets a valid CLAUDE_CODE_OAUTH_TOKEN. The documented precedence ranks the env token (#5) above subscription /login credentials (#6): https://code.claude.com/docs/en/authentication#authentication-precedence

Claude Code shows no warning. A session meant for account A silently runs — and bills usage — against account B.

Environment

  • macOS (Darwin 25.5.0), native installer
  • Reproduced identically on 2.1.173, 2.1.174, and 2.1.175; still unfixed on 2.1.185 (latest on main as of this writing)
  • Two Claude subscription accounts: account A (logged in via /login, creds in the Keychain) and account B (1-year token from claude setup-token)

Steps to reproduce

# Keychain holds account-A /login creds. $TOKEN_B is a valid account-B token
# from `claude setup-token`.

run() {
  CLAUDE_CODE_ENABLE_TELEMETRY=1 OTEL_METRICS_EXPORTER=console OTEL_METRIC_EXPORT_INTERVAL=2000 \
    "$@" claude -p "Reply with just: ok" --model claude-haiku-4-5-20251001 2>&1 | \
    grep -o 'account_uuid[^,}]*' | sort -u
}

run env -u CLAUDE_CODE_OAUTH_TOKEN                                          # A: keychain only
run env CLAUDE_CODE_OAUTH_TOKEN="$TOKEN_B"                                  # B: valid token + keychain
run env CLAUDE_CODE_OAUTH_TOKEN="$TOKEN_B" CLAUDE_CONFIG_DIR=/tmp/empty     # C: valid token, no stored creds

Results

| # | Stored creds | Env token | Account used |
|---|---|---|---|
| A | account-A keychain | unset | A (baseline) |
| B | account-A keychain | valid account-B | A ← bug; docs say the token wins |
| C | none | valid account-B | B (correct) |
| D | account-A keychain | invalid garbage | A — silent fallback, no warning |
| E | none | invalid garbage | 401 Invalid bearer token (correct) |

A and B return the identical account_uuid (account A). C succeeds with the account-B token, proving the token is valid and usable. api.anthropic.com/api/oauth/profile confirms the token is non-expired: it returns a 403 scope error for user:profile, so authentication succeeds and the token is inference-scoped — not a 401.

Expected

Per the documented precedence, case B authenticates with the env token (account B). Failing that, Claude Code warns whenever both credential sources exist and it picks the lower-ranked one.

The behavior looks intentional, and it contradicts the docs

Two changelog entries clear the env token so disk credentials win — the reverse of the documented precedence:

  • 2.1.118 — "Fixed /login having no effect in a session launched with CLAUDE_CODE_OAUTH_TOKEN — the env token is now cleared so disk credentials take effect"
  • 2.1.117 — "Fixed /login having no effect when launched with CLAUDE_CODE_OAUTH_TOKEN env var and that token expires"

These fixes target the /login flow, but the same env-token-cleared logic likely explains why a valid env token never wins at startup. The code and the docs disagree; one of them is wrong.

Related: #16238 (closed not-planned) framed the env token as the winner, matching the docs. Current behavior is the reverse.

Symptom that led here

Interactive sessions launched with the account-B token briefly showed account-B rate-limit state at startup, then flipped to account A's limits after the first message — usage silently billed to the wrong account.

Workaround

Tokens-only auth: /logout the Keychain account, then switch both accounts through per-shell CLAUDE_CODE_OAUTH_TOKEN exports. Cost: Remote Control stops working, because setup-token is inference-only and Keychain creds cannot coexist without re-triggering the bug.

Per-account CLAUDE_CONFIG_DIR is a partial alternative, but the docs specify config-dir credential isolation only via .credentials.json on Linux and Windows. On macOS the creds live in the Keychain, so per-config-dir isolation is unverified, and it fragments plugins, skills, agents, commands, and MCP config across directories.

View original on GitHub ↗

5 Comments

DmitriyAlergant · 2 months ago

Seconding this. Encountered the same.

Claude team needs to either update the docs, or fix the product. Ideally fix the product.

genesiscz · 1 month ago

Still present on 2.1.202 (macOS, Darwin 25.3.0) — but the shape has changed since the 2.1.173–185 results above, and I can add the mechanism.

Same two-account setup (keychain /login = account A; valid claude setup-token for account B in CLAUDE_CODE_OAUTH_TOKEN; both Max 20x). Per-request attribution verified with a local TLS reverse proxy (ANTHROPIC_BASE_URL=https://localhost:8443 + NODE_EXTRA_CA_CERTS=<self-signed cert>) logging the Authorization bearer and the anthropic-organization-id response header.

Headless -p on 2.1.202: now CORRECT

Unlike case B in the original report, every -p variant sent the account-B token on every /v1/messages and returned account B's org id — including:

  • claude -p --model claude-fable-5
  • claude -p --model 'claude-fable-5[1m]' with a 210k-token prompt (so the context-1m-2025-08-07 beta is genuinely exercised)
  • claude -p --resume <a real 300k-token session>

Interactive TUI on 2.1.202: WRONG — and it's a mid-session flip, not static precedence

22:02:18  POST /v1/messages  auth=<token-B>     org=<org-B>  -> 200   ← startup request
22:02:45  POST /v1/messages  auth=<keychain-A>  org=<org-A>  -> 200   ← first user message
22:02:46  POST /v1/messages  auth=<keychain-A>  org=<org-A>  -> 200   ← everything after

The session starts on the env token, then swaps to the keychain credential before the first user message completes. After the flip, /status still displays Auth token: CLAUDE_CODE_OAUTH_TOKEN, and the /usage tab renders account A's utilization. In a real work session this silently billed ~40M cache-read + 270k output tokens to account A in a 17-minute window while account B stayed at 0.0%.

Mechanism (from the 2.1.202 bundle — the native binary embeds readable JS)

The OAuth "401 recovery" path overwrites the env var in-process with the keychain token:

let o = (await Tl().readAsync())?.claudeAiOauth;   // keychain read
if (o?.accessToken && o.accessToken !== e) {
    if (process.env.CLAUDE_CODE_OAUTH_TOKEN) process.env.CLAUDE_CODE_OAUTH_TOKEN = o.accessToken;
    ...
    G("tengu_oauth_401_sdk_callback_refreshed", {}), xe("oauth_401_recovery")

Setup tokens are inference-only scope (telemetry tag tengu_oauth_tokens_inference_only); they 403 on /api/oauth/profile and /api/oauth/usage. Interactive startup touches one of those auth-adjacent surfaces, the failure triggers "recovery", and the keychain token replaces the env token for the rest of the process lifetime. -p mode doesn't run that startup surface, which is why it now behaves correctly.

Notably, the bundle already contains a warning string for exactly this state — it never surfaced in any affected session:

"OAuth token Claude Code is using belongs to a different claude.ai account than the one Claude Code is logged in as. If CLAUDE_CODE_OAUTH_TOKEN is set in your shell or CI profile, unset it (or re-mint it for this account), then run /logout and /login…"

Confirmed workaround (matches case C above)

With no keychain credential present (/logout, or an isolated CLAUDE_CONFIG_DIR whose per-dir-hashed keychain entry is empty), the interactive session keeps the env token for every request — there is nothing to flip to. Two side costs of running keychain-less on a setup token:

  1. The /usage tab goes dark (403 for inference scope).
  2. /model …opus[1m] / …sonnet[1m] switches are refused ("Opus with 1M context is not available for your account") because the tier can't be resolved from an inference-only token — setting CLAUDE_CODE_SUBSCRIPTION_TYPE=max restores them (the credential builder reads it as subscriptionType). The server itself accepts the 1M beta on the setup token, so the block is purely the client-side gate missing tier data.
hunterBhough · 1 month ago

Confirmed still present on 2.1.207 (macOS), with a controlled repro that isolates the precedence inversion from token validity:

Setup: fresh CLAUDE_CONFIG_DIR containing only:

  • settings.json with env.CLAUDE_CODE_OAUTH_TOKEN set to a deliberately corrupted token (verified it returns 401 via a direct curl Bearer probe), and
  • valid stored /login credentials — tested both as .credentials.json and as the per-dir keychain entry (Claude Code-credentials-<sha256(configDir)[:8]>).

Result: the session authenticates cleanly and never surfaces any error — the broken env token is never consulted. Per the documented precedence (env token above subscription /login) this session should hard-fail with a 401.

Inverse control: same dir with a valid env token and no stored credentials → env token is used correctly. And with invalid/expired stored creds + valid env token, startup silently falls back to the env token — which makes the shadowing easy to misdiagnose as "token wins" if you only test with bad stored creds.

The operationally painful part is mid-session: a session that authenticated via stored /login creds and then crosses the ~8h access-token expiry re-reads only the credentials file/keychain in its 401 recovery loop — the valid CLAUDE_CODE_OAUTH_TOKEN sitting in its environment is never consulted. With several concurrent instances sharing one account, the single-use refresh-token rotation means losers of the refresh race sit in Please run /login · API Error: 401 until fresh creds land on disk, even though every one of them had a valid long-lived token available the whole time.

The v2.1.118 changelog line ("Fixed /login having no effect in a session launched with CLAUDE_CODE_OAUTH_TOKEN — the env token is now cleared so disk credentials take effect") appears to be where the inversion became deliberate. If that's the intended design, the docs' precedence table needs updating (#52203) and a startup warning like #16238 proposes would prevent a lot of silent misconfiguration; a fallback to the env token in the 401 recovery path would fix the mid-session stall.

Workaround that holds up in practice: guarantee token-only dirs — remove .credentials.json and the per-dir keychain entry, and keep them from coming back. A dir running purely on the env token never recreates either store.

gecube · 1 month ago

I tried to reproduce this on 2.1.202 (macOS, Enterprise creds in Keychain + valid personal setup-token in CLAUDE_CODE_OAUTH_TOKEN) and found the opposite — but I think the difference is the measurement, not necessarily a fix, so posting details.

The OTEL account_uuid attribute is not ground truth. On 2.1.202 it is populated from the cached oauthAccount in ~/.claude.json (the last interactive /login), not from the credential actually used:

  • keychain-only run → account_uuid = keychain account (as expected)
  • env-token + keychain run → account_uuid = still the keychain account (looks exactly like your case B…)
  • env-token + empty CLAUDE_CONFIG_DIRno account_uuid attribute at all (nothing cached to echo)

Server rate-limit headers tell a different story. With ANTHROPIC_LOG=debug claude -p "ok", the anthropic-ratelimit-unified-* response headers for the env-token run show the personal account's 5h/7d buckets (utilization ~0), while the keychain-only run shows the Enterprise org's usage-based overage claim at 0.99. Identical results with and without stored keychain creds present. So on 2.1.202, inference/billing follows the env token per documented precedence; only the client-side attribution (telemetry, usage toasts, statusline rate_limits — see #68772) reports the keychain account.

Two possibilities: (a) the precedence bug was real on 2.1.173–2.1.185 and has since been fixed, leaving only the display/telemetry misattribution; or (b) case B in the original report was itself measuring the cached-attribution artifact. Either way, re-verifying with the response headers rather than account_uuid would pin it down — and the telemetry attribute lying about the billing account deserves a fix of its own.

gecube · 1 month ago

Still present on 2.1.212 (Homebrew cask, macOS Darwin 25.6.0) — and I can add two data points from a hard failure this morning: the mid-session stall @hunterBhough described does not require a refresh race between concurrent instances, and there's a proxy-free way to show both credentials being used inside one interactive session.

Setup. Keychain /login = Team-org account; CLAUDE_CODE_OAUTH_TOKEN = valid setup-token (sk-ant-oat01-…, subscription-scoped, same org). Single fresh interactive session launched with the env token set; no other instance racing for the refresh.

Failure. The keychain access token had expired ~2h earlier (previous one minted the evening before). Every turn died immediately:

[ERROR] API error (attempt 1/11): 401 401 {"type":"error","error":{"type":"authentication_error","message":"OAuth access token has expired. Re-authenticate to continue."},"request_id":null}
[ERROR] [engine] turn ended in error: Please run /login · API Error: 401 OAuth access token has expired. Re-authenticate to continue.

The full --debug log contains zero refresh attempts for the entire session — not a lost race, never tried. -p with the byte-identical env token worked throughout (consistent with @genesiscz's proxy capture). Recovery came nine minutes later from a bare claude run without the env var, which refreshed the keychain token; at that instant every interactive session started working again, including a --resume of a large existing one.

Both credentials demonstrably in use inside the failing session. The same debug log that shows /v1/messages rejected with "token expired" also shows:

[DEBUG] Policy limits: Fetched successfully

Control experiment: launching interactive with a deliberately corrupted env token (valid keychain) logs

[DEBUG] Policy limits: fetch failed (auth 401) — Request failed with status code 401

so the policy-limits fetch goes out under CLAUDE_CODE_OAUTH_TOKEN whenever it's set. In the failing session that fetch succeeded ⇒ the env token was present and valid at the exact moment /v1/messages was being rejected as expired. The file-based token is static (unchanged for three weeks) and validates fine before and after — a credential can't be expired at 07:04Z and valid at 13:30Z, so the expired token on the messages path was the keychain one. One session, two identities: auxiliary fetches under the env token, inference under the keychain. (The startup banner meanwhile says "· Claude API", i.e. claims the env-token identity — the attribution side of this is #77993.)

Why no self-recovery — the refresh is structurally disabled, not raced away. From the 2.1.212 binary, the stored-credential getter short-circuits when the env var is set:

if(Z.CLAUDE_CODE_OAUTH_TOKEN)return{accessToken:Z.CLAUDE_CODE_OAUTH_TOKEN,refreshToken:null,expiresAt:null,...}

and the refresh wrapper bails out the same way:

if(Z.CLAUDE_CODE_OAUTH_TOKEN)return bs();

So with the env token set, the credential layer reports refreshToken: null and the refresher never fires — while the interactive messages path still authenticates with the (now expired) keychain token it refuses to refresh. Worst of both: inference pinned to the keychain account, keychain maintenance disabled by the env var. Any process that inherits CLAUDE_CODE_OAUTH_TOKEN (shell profile, CI, wrapper alias) is enough to guarantee the stall on its own; concurrent instances only add the refresh-race variant on top.

Cross-reference: #80496 reports the same user-visible signature (Team org, setup-token, interactive-only "OAuth access token has expired", -p fine) on 2.1.218 and attributes it to channels/nonessential traffic, with CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1 as a fix. In my instance the 401s were genuine /v1/messages rejections, timed exactly to the keychain token's expiry and healed exactly by its refresh — auth precedence, not auxiliary-traffic scope. The two may still be one bug: if it's the auxiliary startup traffic (quota check, policy fetch) that binds the interactive engine to the keychain account, disabling it would keep the turn on the env token, which is what that flag appears to do.

Concrete asks, echoing @hunterBhough: (1) consult the env token in the 401 recovery path; (2) don't disable stored-credential refresh while the env var is set, as long as the messages path keeps using stored credentials; (3) or simply restore the documented precedence, which makes both problems unrepresentable.