Stored /login credentials silently beat a valid CLAUDE_CODE_OAUTH_TOKEN (contradicts documented auth precedence)
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
mainas of this writing) - Two Claude subscription accounts: account A (logged in via
/login, creds in the Keychain) and account B (1-year token fromclaude 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
/loginhaving no effect in a session launched withCLAUDE_CODE_OAUTH_TOKEN— the env token is now cleared so disk credentials take effect" - 2.1.117 — "Fixed
/loginhaving no effect when launched withCLAUDE_CODE_OAUTH_TOKENenv 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.
5 Comments
Seconding this. Encountered the same.
Claude team needs to either update the docs, or fix the product. Ideally fix the product.
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; validclaude setup-tokenfor account B inCLAUDE_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 theAuthorizationbearer and theanthropic-organization-idresponse header.Headless
-pon 2.1.202: now CORRECTUnlike case B in the original report, every
-pvariant sent the account-B token on every/v1/messagesand returned account B's org id — including:claude -p --model claude-fable-5claude -p --model 'claude-fable-5[1m]'with a 210k-token prompt (so thecontext-1m-2025-08-07beta 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
The session starts on the env token, then swaps to the keychain credential before the first user message completes. After the flip,
/statusstill displaysAuth token: CLAUDE_CODE_OAUTH_TOKEN, and the/usagetab 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:
Setup tokens are inference-only scope (telemetry tag
tengu_oauth_tokens_inference_only); they 403 on/api/oauth/profileand/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.-pmode 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:
Confirmed workaround (matches case C above)
With no keychain credential present (
/logout, or an isolatedCLAUDE_CONFIG_DIRwhose 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:/usagetab goes dark (403 for inference scope)./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 — settingCLAUDE_CODE_SUBSCRIPTION_TYPE=maxrestores them (the credential builder reads it assubscriptionType). The server itself accepts the 1M beta on the setup token, so the block is purely the client-side gate missing tier data.Confirmed still present on 2.1.207 (macOS), with a controlled repro that isolates the precedence inversion from token validity:
Setup: fresh
CLAUDE_CONFIG_DIRcontaining only:settings.jsonwithenv.CLAUDE_CODE_OAUTH_TOKENset to a deliberately corrupted token (verified it returns 401 via a directcurlBearer probe), and.credentials.jsonand 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_TOKENsitting 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 inPlease run /login · API Error: 401until 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.jsonand the per-dir keychain entry, and keep them from coming back. A dir running purely on the env token never recreates either store.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_uuidattribute is not ground truth. On 2.1.202 it is populated from the cachedoauthAccountin~/.claude.json(the last interactive/login), not from the credential actually used:account_uuid= keychain account (as expected)account_uuid= still the keychain account (looks exactly like your case B…)CLAUDE_CONFIG_DIR→ noaccount_uuidattribute at all (nothing cached to echo)Server rate-limit headers tell a different story. With
ANTHROPIC_LOG=debug claude -p "ok", theanthropic-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-basedoverageclaim 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, statuslinerate_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_uuidwould pin it down — and the telemetry attribute lying about the billing account deserves a fix of its own.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= validsetup-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:
The full
--debuglog contains zero refresh attempts for the entire session — not a lost race, never tried.-pwith the byte-identical env token worked throughout (consistent with @genesiscz's proxy capture). Recovery came nine minutes later from a bareclauderun without the env var, which refreshed the keychain token; at that instant every interactive session started working again, including a--resumeof a large existing one.Both credentials demonstrably in use inside the failing session. The same debug log that shows
/v1/messagesrejected with "token expired" also shows:Control experiment: launching interactive with a deliberately corrupted env token (valid keychain) logs
so the policy-limits fetch goes out under
CLAUDE_CODE_OAUTH_TOKENwhenever it's set. In the failing session that fetch succeeded ⇒ the env token was present and valid at the exact moment/v1/messageswas 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:
and the refresh wrapper bails out the same way:
So with the env token set, the credential layer reports
refreshToken: nulland 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 inheritsCLAUDE_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",
-pfine) on 2.1.218 and attributes it to channels/nonessential traffic, withCLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1as a fix. In my instance the 401s were genuine/v1/messagesrejections, 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.