[BUG] OAuth refresh returns 400 after early 401 before local expiresAt; concurrent sessions forced to /login

Status Closed — not planned
Reported on v2.1.121
Maintainer reply None cached
Activity 15 comments · opened Apr 28, 2026 · closed Aug 15, 2026

Summary

Claude Code OAuth sessions are being rejected by the server before the locally stored expiresAt time. Claude Code then attempts OAuth refresh, but POST https://platform.claude.com/v1/oauth/token returns HTTP 400. The user-facing result is repeated Please run /login / API Error: 401 Invalid authentication credentials prompts every few hours, sometimes much sooner.

This appears related to the early-revocation refresh path discussed in #52202 and potentially to the concurrent-session refresh-token race described in #24317, but this report includes Linux logs, request IDs, and the exact observed sequence from Claude Code 2.1.121.

I have intentionally omitted account email, account UUIDs, raw credential files, access tokens, and refresh tokens from this public issue. I can provide full debug logs privately to Anthropic if needed.

Environment

  • Claude Code version: 2.1.121
  • Install path: native installer, ~/.local/bin/claude -> ~/.local/share/claude/versions/2.1.121
  • OS: Ubuntu 25.10, Linux x86_64
  • Auth mode: Claude.ai OAuth / Max subscription, not API-key billing
  • Shell/session manager: zsh inside tmux
  • Common command wrapper:
  • cc is only an alias for NODE_OPTIONS="--max-old-space-size=32768" ~/.local/bin/claude --dangerously-skip-permissions
  • It does not call any account-switching tool or set auth env vars.

What Happened

After a successful login, Claude Code works for a while. Later, while the local OAuth credential still has a future expiresAt, API calls begin returning:

401 {"type":"error","error":{"type":"authentication_error","message":"Invalid authentication credentials"}}

Claude Code then attempts to refresh OAuth credentials:

POST https://platform.claude.com/v1/oauth/token

but the refresh request fails with:

status=400 AxiosError: Request failed with status code 400

After that, Claude Code prompts for /login again.

Precise Timeline From Latest Captured Failure

All timestamps below are UTC.

Before this failure cluster, the active credential had been refreshed at approximately:

2026-04-28T16:59:24Z

and its local OAuth expiry was approximately:

2026-04-29T00:59:24Z

So the failures below happened about 5 hours before the locally stored expiry.

Two Claude Code sessions were active in different tmux panes, sharing the same Unix user and credential store:

PID 1118880 started 2026-04-28 15:22:47Z
  cwd=/data/projects/ddis/ferratomic
  cmd=/home/ubuntu/.local/bin/claude --dangerously-skip-permissions --debug-file ~/.claude/debug-logs/claude-oauth-20260428T152248Z.log --resume ...

PID 1155909 started 2026-04-28 16:13:13Z
  cwd=/data/projects/rr-edge
  cmd=/home/ubuntu/.local/bin/claude --dangerously-skip-permissions --debug-file ~/.claude/debug-logs/claude-oauth-20260428T161314Z.log --resume ...

The first fresh 401 in the latest cluster appeared in the second session:

2026-04-28T19:58:52.417Z /v1/messages -> 401
request_id=req_011CaWm7iW1eWEpXC5FQf6HM
source=agent:custom:codex:codex-rescue

2026-04-28T19:58:52.874Z /v1/messages -> 401
request_id=req_011CaWm7kQMqRBywN9kq8pJo
source=repl_main_thread

2026-04-28T19:58:53.736Z OAuth refresh attempt:
POST https://platform.claude.com/v1/oauth/token -> 400

2026-04-28T19:58:54.136Z retry /v1/messages -> 401
request_id=req_011CaWm7qxjXcMySoRUCPjt3
source=agent:custom:codex:codex-rescue

2026-04-28T19:58:54.143Z retry /v1/messages -> 401
request_id=req_011CaWm7r5BPaU9S8CACXkrA
source=repl_main_thread

2026-04-28T19:58:55.505Z OAuth refresh attempt:
POST https://platform.claude.com/v1/oauth/token -> 400

Additional failures continued in the same session:

2026-04-28T19:58:57.786Z /v1/messages -> 401
request_id=req_011CaWm87MCDAKvnGCuGvsnz

2026-04-28T19:59:00.255Z retry /v1/messages -> 401
request_id=req_011CaWm8ESqEEduL2MqWmryS

2026-04-28T19:59:18.522Z /v1/messages -> 401
request_id=req_011CaWm9eRzyADJVd7GczwLZ
source=agent:builtin:general-purpose

2026-04-28T19:59:20.834Z retry /v1/messages -> 401
request_id=req_011CaWm9pUVE46W7t4vMYa5g

2026-04-28T19:59:23.646Z /v1/messages -> 401
request_id=req_011CaWmA2PMKwatEgYsZQrzA
source=repl_main_thread

2026-04-28T19:59:24.746Z retry /v1/messages -> 401
request_id=req_011CaWmA6HVx9BhQ7N1tRLHC

Roughly one minute later, the other active Claude Code session also failed:

2026-04-28T19:59:52.475Z /v1/messages -> 401
request_id=req_011CaWmC8zmbUxPDiPRP1xDN
source=away_summary

2026-04-28T19:59:53.349Z OAuth refresh attempt:
POST https://platform.claude.com/v1/oauth/token -> 400

2026-04-28T19:59:53.687Z retry /v1/messages -> 401
request_id=req_011CaWmCEmHvYQkXf3LquzNH

2026-04-28T19:59:55.037Z OAuth refresh attempt:
POST https://platform.claude.com/v1/oauth/token -> 400

The user reauthenticated after this, at approximately:

2026-04-28T20:00:56Z

The new credential then had:

expiresAt=2026-04-29T04:00:56Z

Earlier Captured Examples

Earlier in the same investigation, after a fresh login around 2026-04-28T15:24:04Z, the active credential had:

expiresAt=2026-04-28T23:24:04Z

but a later session failed well before that expiry:

2026-04-28T16:24:21Z /v1/messages -> 401
request_id=req_011CaWUkyMHCnP23HHubcq8W

retry request_id=req_011CaWUm3pR2mmKyeA8xPp7a

OAuth refresh attempts returned 400.

Another later failure before local expiry:

2026-04-28T17:34:04Z /v1/messages -> 401
request_id=req_011CaWa5HnPR2siADk2kfVse

retry request_id=req_011CaWa5P1Qy1yg5vHPSNXe7

OAuth refresh attempts returned 400.

Diagnostics Already Performed

The following local causes were checked and do not appear to explain the issue:

  • No ANTHROPIC_API_KEY, ANTHROPIC_AUTH_TOKEN, or CLAUDE_CODE_OAUTH_TOKEN in a clean shell.
  • Running Claude Code process environments did not contain those auth override env vars.
  • ANTHROPIC_CUSTOM_HEADERS was not present according to debug logs.
  • The active auth file path is a single symlinked credential store:
  • ~/.config/claude-code/auth.json -> ~/.claude/.credentials.json
  • This is OAuth auth, not API credits/API-key auth. API credits are empty and not intended to be used.

Why This Looks Like an OAuth Refresh/Rotation Problem

The local credential expiry timestamp was still in the future, but Anthropic returned 401 anyway. Claude Code appeared to correctly treat the server as authoritative and attempted refresh, which matches the behavior described by the v2.1.118 changelog and #52202.

The failing part is that the refresh grant returned HTTP 400. In the latest captured cluster, multiple concurrent Claude Code sessions and background agents were sharing one credential store. That makes a refresh-token rotation race plausible:

  • Process A has an old refresh token in memory.
  • Process B or a later login rotates credentials on disk.
  • Process A later receives 401 and tries refreshing with its stale in-memory refresh token.
  • The token endpoint returns 400.
  • Other active sessions then begin failing too.

If refresh tokens are single-use and stale refresh attempts can invalidate the token family, this would explain the observed cascading /login prompts.

Expected Behavior

Claude Code should be able to run multiple concurrent sessions for the same logged-in user without causing early OAuth invalidation or repeated forced reauthentication.

At minimum, when a process receives 401 before local expiry and has multiple Claude Code processes sharing the same credential store, it should avoid using stale in-memory refresh tokens if a newer credential exists on disk. Ideally, refresh should be serialized with a lock / compare-and-swap flow, and 400 responses from the token endpoint should be logged with enough non-secret detail to distinguish invalid_grant, revoked account, stale refresh token, and other causes.

Questions for Anthropic

  1. Are Claude Code OAuth refresh tokens rotating and single-use?
  2. If an older Claude Code process attempts to use a stale refresh token, can that invalidate the current refresh token family or account session?
  3. Does Claude Code 2.1.121 serialize refresh attempts across processes sharing one ~/.claude/.credentials.json?
  4. Before attempting refresh after a 401, does Claude Code re-read disk and compare whether another process has already written newer credentials?
  5. Can Anthropic inspect the request IDs above and identify whether the token endpoint 400 was invalid_grant, account/session revocation, stale refresh token reuse, org/account policy, or something else?

Related Issues

  • #52202 documents that OAuth handling should account for server-side early revocation before local expiry.
  • #24317 appears related to concurrent Claude Code sessions and possible OAuth refresh-token races.
  • #48079 and #53832 look related to persistent 401s after successful OAuth login, though the environment and exact failure pattern differ.

Additional Notes

I am concerned that repeated /login flows could be interpreted as suspicious account activity. Guidance on whether frequent re-authentication attempts are safe, and whether users should stop all existing Claude Code processes before logging in again, would be very helpful.

View original on GitHub ↗

14 Comments

github-actions[bot] · 4 months ago

Found 3 possible duplicate issues:

  1. https://github.com/anthropics/claude-code/issues/24317
  2. https://github.com/anthropics/claude-code/issues/36911
  3. https://github.com/anthropics/claude-code/issues/45551

This issue will be automatically closed as a duplicate in 3 days.

  • If your issue is a duplicate, please close it and 👍 the existing issue instead
  • To prevent auto-closure, add a comment or 👎 this comment

🤖 Generated with Claude Code

GFalcaoPaias · 4 months ago

Adding a single-session data point to prevent auto-closure:

Same forced re-login pattern, but without concurrent sessions — rules out the refresh-token race as the only cause.

Environment:

  • Claude Code 2.1.123, WSL2 (Ubuntu on Windows), bash
  • Auth: Claude.ai OAuth, Pro subscription
  • Single session only

Observed pattern:
Inspecting ~/.claude/.credentials.json, the expiresAt after /login is consistently ~8 hours in the future (expiresAt timestamp confirms this). On next day's first launch, Claude Code prompts /login again — the refresh token is present on disk but does not silently renew the access token.

This suggests the refresh failure is not solely a multi-process race: even a single quiescent session fails to renew. The server appears to invalidate the access token before local expiresAt, and the subsequent refresh grant returns 400.

wvandaal · 4 months ago

Additional update with a fresh reproduction from the same machine/session. This is sanitized: no raw access or refresh tokens are included.

Fresh failure: 2026-04-30T23:26Z

The failure happened again at approximately 2026-04-30T23:26:00Z (2026-04-30 19:26 EDT) while running Claude Code 2.1.123 from the native install.

Active Claude Code process at the time:

/home/ubuntu/.local/bin/claude --dangerously-skip-permissions --debug-file /home/ubuntu/.claude/debug-logs/claude-oauth-single-20260429T225317Z.log --resume 281f1725-900e-4e12-bf0b-50e6a89bd588

The debug log attribution line for this request shows:

cc_version=2.1.123.ac1; cc_entrypoint=cli

Request / refresh sequence

Relevant debug-log sequence:

2026-04-30T23:25:59.803Z [DEBUG] [API:request] Creating client, ANTHROPIC_CUSTOM_HEADERS present: false, has Authorization header: false
2026-04-30T23:25:59.804Z [DEBUG] [API:auth] OAuth token check starting
2026-04-30T23:25:59.813Z [DEBUG] [API:auth] OAuth token check complete
2026-04-30T23:25:59.868Z [DEBUG] [API REQUEST] /v1/messages x-client-request-id=441f5b3d-fb00-4324-84fd-c7019c9f14b4 source=repl_main_thread
2026-04-30T23:26:00.336Z [ERROR] API error (attempt 1/11): 401 401 {"type":"error","error":{"type":"authentication_error","message":"Invalid authentication credentials"},"request_id":"req_011CaapXekpkHg7sWhbXFqyv"}
2026-04-30T23:26:01.382Z [ERROR] AxiosError: [url=https://platform.claude.com/v1/oauth/token,status=400] AxiosError: Request failed with status code 400
2026-04-30T23:26:01.479Z [DEBUG] [API:request] Creating client, ANTHROPIC_CUSTOM_HEADERS present: false, has Authorization header: false
2026-04-30T23:26:01.515Z [DEBUG] [API:auth] OAuth token check starting
2026-04-30T23:26:01.520Z [DEBUG] [API:auth] OAuth token check complete
2026-04-30T23:26:01.535Z [DEBUG] [API REQUEST] /v1/messages x-client-request-id=34502c89-d918-405a-9299-b00da60d5458 source=repl_main_thread
2026-04-30T23:26:01.814Z [ERROR] API error (attempt 2/11): 401 401 {"type":"error","error":{"type":"authentication_error","message":"Invalid authentication credentials"},"request_id":"req_011CaapXmUtThgaUGibu4Gh5"}
2026-04-30T23:26:03.032Z [ERROR] Error in API request: 401 {"type":"error","error":{"type":"authentication_error","message":"Invalid authentication credentials"},"request_id":"req_011CaapXmUtThgaUGibu4Gh5"}
2026-04-30T23:26:03.033Z [ERROR] API error x-client-request-id=34502c89-d918-405a-9299-b00da60d5458 (give this to the API team for server-log lookup)

Important IDs:

first x-client-request-id: 441f5b3d-fb00-4324-84fd-c7019c9f14b4
first API request_id:     req_011CaapXekpkHg7sWhbXFqyv
second x-client-request-id:34502c89-d918-405a-9299-b00da60d5458
second API request_id:    req_011CaapXmUtThgaUGibu4Gh5
oauth refresh endpoint:   https://platform.claude.com/v1/oauth/token
oauth refresh status:     400

Credential file state immediately after failure

Active credential file:

/home/ubuntu/.claude/.credentials.json

Claude Code's alternate auth path is just a symlink to the same file:

/home/ubuntu/.config/claude-code/auth.json -> /home/ubuntu/.claude/.credentials.json

Sanitized credential metadata immediately after the failure:

stat_mtime=2026-04-30 19:26:01.412984207 -0400
size=363
inode=1115784
mode=600
expiresAt_utc=2026-05-01T03:49:49Z
subscriptionType=max
rateLimitTier=default_claude_max_20x
scopes=user:file_upload,user:inference,user:mcp_servers,user:profile,user:sessions:claude_code
access_len=108
refresh_len=0
access_sha256=5bef838b5e553dd28de487a8a2f1e51c4033892eff284c508ae969bf7852855c
refresh_sha256=e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855

refresh_sha256=e3b0... is the SHA-256 of the empty string. So after this failure, the active on-disk credential file contains a non-empty access token, a future expiresAt, and an empty refresh token.

The credential file mtime lines up with the failed refresh attempt:

2026-04-30T23:26:01.382Z oauth/token returned 400
2026-04-30 19:26:01.412984207 -0400 .credentials.json rewritten

Other local files touched in the same narrow window were only session/history/debug-log files:

2026-04-30 19:25:59.8840213770 /home/ubuntu/.claude/history.jsonl
2026-04-30 19:25:59.9570196020 /home/ubuntu/.claude/file-history/281f1725-900e-4e12-bf0b-50e6a89bd588/5d8cc94b3593d23d@v3
2026-04-30 19:26:01.4129842070 /home/ubuntu/.claude/.credentials.json
2026-04-30 19:26:03.2469396240 /home/ubuntu/.claude/sessions/2734866.json
2026-04-30 19:26:03.3579369260 /home/ubuntu/.claude/projects/-data-projects-rr-edge/281f1725-900e-4e12-bf0b-50e6a89bd588.jsonl
2026-04-30 19:26:08.0428230430 /home/ubuntu/.claude/debug-logs/claude-oauth-single-20260429T225317Z.log

No apparent local override / shadow credential source

The running Claude process environment had no active auth overrides:

no ANTHROPIC_API_KEY
no ANTHROPIC_AUTH_TOKEN
no CLAUDE_CODE_OAUTH_TOKEN
no CLAUDE_CONFIG_DIR
no CAAM_HOME
no XDG_CONFIG_HOME
ANTHROPIC_CUSTOM_HEADERS present: false
has Authorization header: false

The only Claude process running was this same long-running ~/.local/bin/claude process. No CAAM daemon/process/service was running. I also fingerprinted old CAAM/backup credential files; the active access token hash did not match any old disabled CAAM vault credential. Based on the local audit, this does not look like a shadow credential source winning precedence.

What this seems to show

The latest reproduction looks like:

  1. Claude Code considers OAuth auth locally present and proceeds with /v1/messages.
  2. The API rejects the access token early with 401 authentication_error even though the local expiresAt is still in the future.
  3. Claude Code attempts refresh at https://platform.claude.com/v1/oauth/token.
  4. Refresh returns 400.
  5. The active credential file is left/re-written with refreshToken length 0, while retaining a non-empty access token and future expiresAt.
  6. Subsequent requests continue to fail with 401 and the CLI asks for /login.

This is happening with OAuth / Claude Max subscription auth, not API key auth.

wvandaal · 4 months ago

Additional update: I now have a sanitized MITM capture of the OAuth refresh request/response. No raw access tokens or refresh tokens are included below.

Key finding

Claude Code sent the exact refresh token that was present in .credentials.json at request time. The token endpoint rejected it with:

HTTP 400
error=invalid_grant
error_description="Refresh token not found or invalid"

So this does not appear to be a local credential-precedence issue where Claude Code is sending some other refresh token from another file. The refresh token sent in the request matched the active on-disk credential file by SHA-256 hash.

Captured refresh request

Sanitized MITM capture for the refresh request:

url=https://platform.claude.com/v1/oauth/token
method=POST
grant_type=refresh_token
client_id=9d1c250a-e61b-44d9-88ed-5944d1962f5e
request_body_len=306
request refresh_token.len=108
request refresh_token.sha256=51751650366a2e6b9fc235bfb93d143828f0e509a7e0ec9b5f568ea4e6c4fb17
credential_snapshot refreshToken.len=108
credential_snapshot refreshToken.sha256=51751650366a2e6b9fc235bfb93d143828f0e509a7e0ec9b5f568ea4e6c4fb17
credential_snapshot accessToken.len=108
credential_snapshot accessToken.sha256=9983e065650516ecf48c9a8e6a76c5810b74fe8aa03696092d08d1c56ff90466
credential_snapshot expiresAt=1777674560792
credential_snapshot file_mtime=2026-05-01T16:29:20.939Z approximately

The request refresh-token hash and the active credential-file refresh-token hash are identical.

Captured token endpoint response

status_code=400
content_type=application/json
response_body_len=85
response_body_sha256=f15c07e8be913512f27592fae9fcc3e352b7fd1a019f882a1255a99eb1e57e98
error=invalid_grant
error_description="Refresh token not found or invalid"

Claude Code debug log sequence

This was on Claude Code 2.1.126 according to the debug log attribution line:

cc_version=2.1.126.a4b; cc_entrypoint=cli

Relevant sequence:

2026-05-01T16:29:35.406Z [API REQUEST] /v1/messages x-client-request-id=2f46ed3c-ef43-4f33-bcae-06ae521eea4d source=repl_main_thread
2026-05-01T16:29:35.588Z API error (attempt 1/11): 401 ... request_id=req_011CacAarGXvJThUYpe5172k
2026-05-01T16:29:36.554Z AxiosError: [url=https://platform.claude.com/v1/oauth/token,status=400]
2026-05-01T16:29:36.588Z [API REQUEST] /v1/messages x-client-request-id=aae7eadc-93d5-4dd5-8470-bf35e0a759e7 source=repl_main_thread
2026-05-01T16:29:36.748Z API error (attempt 2/11): 401 ... request_id=req_011CacAawLsu1kkjK8Tqsomt
2026-05-01T16:29:37.899Z API error x-client-request-id=aae7eadc-93d5-4dd5-8470-bf35e0a759e7

Credential file after the failed refresh

Immediately after the token endpoint returned invalid_grant, the active credential file was rewritten to a broken state with an empty refresh token:

path=/home/ubuntu/.claude/.credentials.json
stat_mtime=2026-05-01 12:29:36.558486381 -0400
size=363
expiresAt_utc=2026-05-01T22:29:20Z
access_len=108
refresh_len=0
access_sha256=9983e065650516ecf48c9a8e6a76c5810b74fe8aa03696092d08d1c56ff90466
refresh_sha256=e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855

e3b0c442... is the SHA-256 of the empty string.

Summary

The observed behavior is:

  1. OAuth login succeeds and returns access + refresh tokens.
  2. .credentials.json contains a non-empty refresh token.
  3. At refresh time, Claude Code sends the same refresh token from .credentials.json.
  4. https://platform.claude.com/v1/oauth/token returns 400 invalid_grant: Refresh token not found or invalid.
  5. Claude Code then rewrites .credentials.json with refreshToken length 0, while retaining a non-empty access token and future expiresAt.
  6. Subsequent API calls fail with 401 authentication_error and require /login.

This is OAuth / Claude Max subscription auth, not API-key auth.

mgajda · 4 months ago

Still reproducing on Claude Code 2.1.126 — Linux.

wvandaal · 3 months ago

Still reproducing with Claude Code v2.1.126 on Ubuntu 24.04.

wvandaal · 3 months ago

@ThariqS @bcherny This is honestly an incredibly prohibitive bug to work around. I've had to re-authenticate over a dozen times just today and I'm honestly concerned that it may lead to my account being banned due to how often I am re-triggering the auth flow. For how good Claude Code has been historically, I'm honestly ready to switch to Codex/Pi/Opencode if it means I don't have to deal with this. Loop, agent teams, long running tasks, all of these functions are effectively broken and useless when a 12-hour window requires a dozen or more manual re-authentication passes. I've already submitted support requests over two weeks ago and have heard nothing—pretty bad service for a product that basically demands that you use its harness and nothing else. Would really appreciate some kind of attention to this matter since there is literally nothing at this point I can do.

Lycheen97 · 3 months ago

Same symptoms on WSL2 / 2.1.143 / Max plan — adding environment data point.

Setup:

  • Claude Code 2.1.143 (auto-updated 2.1.141 → 142 → 143 over last 3 days)
  • WSL2 Ubuntu (Windows host), x86_64
  • Max plan, rateLimitTier: default_claude_max_5x
  • Linux file-based credential store at ~/.claude/.credentials.json (mode 600), no keyring (WSL2 doesn't run gnome-keyring-daemon)
  • Access token TTL observed = exactly 8h (matches OP)

Observed pattern:

  • 2-3 concurrent Claude Code sessions in separate WSL terminals (standard daily workflow)
  • Forced /login ~1x/day, sometimes multiple times/day
  • Two failure modes:
  • New session opens → immediately prompts /login
  • Mid-session: prompt sent → silent hang → discover invalidation on next attempt
  • Single /login from any one session restores ALL sessions simultaneously — confirms shared-file invalidation, not per-session issue
  • Timing not correlated with the auto-upgrade window — suggests refresh-token race rather than version-rotation invalidation

Haven't captured 400/401 logs as cleanly as OP, so can't independently confirm the exact 400 from the refresh grant, but the user-visible cascade matches exactly.

No workaround for multi-session users — running one session at a time defeats most of Claude Code's value for heavy users.

potterdigital · 3 months ago
EDIT (2026-05-22): I originally claimed claude setup-token removed the [1m] context tier. That was wrong — I was reading UI symptoms (no [1m] variant in /model menu, no (1M context) parenthetical in some status lines) and inferring a capability change. Direct test (launching with CLAUDE_CODE_OAUTH_TOKEN set, then running /context) confirms the cap is still Xk/1m and the status reports Opus 4.7 (1M context) ... Claude Max. The auth method (OAuth vs. setup-token) changes some UI text but not the actual tier or cap. Both authenticate as your Max subscription. /context is the only reliable indicator — don't trust status line or /model menu rendering. Corrections applied below; the workaround itself is unchanged.

---

Workaround for anyone hitting this with multiple concurrent sessions on macOS. Validated against the 2.1.149 binary so the isolation claim isn't surface-level.

Approach: give each interactive session its own CLAUDE_CONFIG_DIR. A cc shell function auto-picks the lowest unused slot from 12 candidates so you don't have to track which one you're in. Shared config (CLAUDE.md, agents, skills, settings, MCP, projects/) symlinks back to ~/.claude/ so nothing diverges.

Gist: https://gist.github.com/potterdigital/a2e76ef12904e009725a58e947199314

The non-obvious bit that makes this actually work: CC keys its macOS Keychain entry by CLAUDE_CONFIG_DIR. Default → Claude Code-credentials. With the env var set → Claude Code-credentials-<sha256(path)[:8]>. Each slot ends up with its own keychain row holding independent access + refresh tokens. Verify with:

security dump-keychain ~/Library/Keychains/login.keychain-db | grep '"svce"<blob>="Claude Code'

After each slot has done one /login, their refresh tokens are independent — no race.

Simpler alternative if you're OK with a long-lived static credential: claude setup-token produces a 1-year CLAUDE_CODE_OAUTH_TOKEN. Static tokens don't refresh, so the race can't fire at all. Preserves Max tier and 1M context (per the EDIT above). Tradeoff: a static long-lived secret on disk vs. the per-slot OAuth refresh approach above. Pick based on your threat model.

Caveats:

  • One /login per slot the first time you use it (if you go the per-slot route).
  • zsh-only as written.
  • Tested on macOS / CC 2.1.149. Linux file-based isolation should still help but I haven't validated.
  • Trust /context, not status line text. Auth method affects display rendering inconsistently — don't infer capability from what the status bar shows.

Not a fix — a band-aid until the real one lands (tracked in #56339). But the per-CLAUDE_CONFIG_DIR keychain keying isn't widely documented and seemed worth surfacing.

torrejonv · 3 months ago

Adding a macOS data point with a captured 24-hour token trace — same "rejected before local expiresAt" pattern, dual-store (Keychain + file) drift.

This thread is Linux/WSL2-heavy, so here's macOS evidence that matches the described failure mode, including a continuous sampled timeline.

Environment

  • Claude Code 2.1.158 (native installer, auto-updating ~1–2×/day)
  • macOS (Darwin 25.5.0), Apple Silicon
  • Max 20x plan, OAuth (claude.ai)
  • Credentials stored in both macOS Keychain (Claude Code-credentials) and ~/.claude/.credentials.json (claudeAiOauth) — both actively written
  • ~23 concurrent claude processes (agent-team --name workers + multiple interactive sessions + desktop app)
  • Symptom: forced /login multiple times/day, no Keychain prompt (Keychain access works — the token is rejected)

Method: a LaunchAgent sampled credential state every 5 min for 24h. Tracked: access-token expiresAt, a sha256 fingerprint of the refresh token (to detect rotation), the count of orphaned Claude Code-credentials-<hash> Keychain entries (each forced re-login mints new ones), and numStartups.

Captured timeline (refresh-token fingerprints, no secrets):

| Time (local) | Observation | Reading |
|---|---|---|
| May 30 18:24–23:41 | stable; rtoken 88bc…, accessExp 02:02, 0 orphans | healthy |
| May 30 ~23:46 | orphan Keychain entries 0→2, no rotation, no startup change | forced re-login #1 |
| May 31 ~02:01 | rtoken 88bc…aae9…, accessExp 02:02→09:59 | refresh rotated token |
| May 31 02:01→16:10 | accessExp frozen at 09:59 (expired ~6h) while 23 procs live; Keychain mdat also frozen | refresh failing — token expired on disk, none persisted a successful refresh |
| May 31 ~16:10–18:10 | rtoken aae9…a93c…, accessExp→next-day 02:10, numStartups +3, new orphan entry | forced re-login #2 |

Two things this adds to the report:

  1. Refresh-token rotation is observable — 3 distinct fingerprints in 24h (88bc…aae9…a93c…). Any process still holding a superseded token gets rejected, consistent with the 400 on refresh described here.
  2. The Keychain and file stores drift — at 18:10 the file held a token written at 18:10 while the Keychain entry was last written at 16:10 (≈2h apart). On macOS the two credential stores are not kept in lockstep, which compounds the concurrent-refresh race: different processes can read different "current" refresh tokens.

Net: with N concurrent processes sharing one rotating refresh token, the first to refresh invalidates the token the others hold → they hit 400/401 before their local expiresAt/login cascade. Reducing concurrent sessions reduces frequency but doesn't eliminate it. Happy to share the (redacted) raw 5-min sample log if useful.

torrejonv · 3 months ago

Follow-up to my earlier macOS report: tested the file-based refresher workaround — it fires cleanly but does NOT stop the logout cascade for a multi-session setup, and the trace shows why.

I ran the community LaunchAgent refresher (Alandougherty/claude-code-headless-macos) for ~16h on the same machine (~24 concurrent claude processes, macOS, Max plan). It refreshes ~/.claude/.credentials.json every 15 min, but only within 30 min of expiresAt.

The refresher worked mechanically — two clean refreshes:

01:40  Refresh OK. New expiresAt = 09:40   (rtoken a93c… → eb3f…)
09:11  Refresh OK. New expiresAt = 17:11   (rtoken eb3f… → 0810…)

The file token was always fresh; the "expired-on-disk dead zone" from my previous comment was gone from 01:43→09:04.

But a forced-login cascade still happened at ~09:04, before the 09:11 refresh, while the file token was still valid (exp 09:40):

| Time | procs | orphan keychain entries | rtoken | accessExp |
|------|-------|------|--------|-----------|
| 02:10→09:04 | 24 | 2 | eb3f… | 09:40 (valid) |
| 09:04 | 26 | 2 → 6 | eb3f… | 09:40 |
| 09:09 | 27 | 6 | eb3f… | 09:40 |

Why it still fails — and why a file-refresher fundamentally can't fix the concurrent case: the refresher's 01:40 refresh rotated the single-use refresh token (a93c→eb3f). Every session that had been running since before 01:40 still held a93c in memory. They were idle overnight (no requests → no refresh attempts → no failures), then on first morning use their in-memory access token (expired 02:10) needed a refresh — they presented the now-invalidated a93cinvalid_grant → forced /login across multiple sessions (orphan entries 2→6).

So a single external refresher only helps cold-start/new processes that re-read the file. It cannot help already-running sessions — and by rotating the token it actively strands them. The failure scales with the number of long-lived concurrent sessions.

Takeaway: this strongly suggests the real fix needs to be in-client — e.g. coordinated/single-flight refresh shared across all local Claude processes (file-locked), a short grace window where the previous refresh token stays valid, or non-rotating refresh tokens for the CLI. An external file refresher is not sufficient when multiple long-lived sessions each hold their own in-memory token.

wartzar-bee · 3 months ago

This looks like the classic concurrent refresh-token race: two processes share ~/.claude/.credentials.json and both hold the same refresh token in memory; process B rotates it (new token written to disk), but process A still has the old one cached, hits a 401, tries to refresh with the stale token, and the server rejects the whole token family — kicking both sessions to /login.

Two client-side mitigations that don't need a server-side grace period:

  1. Re-read the credential file right before refreshing. If the on-disk token is newer than the one in memory, another process already rotated it — use that instead of firing a refresh.
  2. File-lock the refresh so concurrent processes serialize (single-flight) instead of racing.

Claude Code doesn't do either yet, so your asks (#3/#4) are the right ones. I wrote a small single-flight implementation of this for shared-credential OAuth clients as a reference: https://github.com/wartzar-bee/refresh-guard — might be useful as a model for a patch.

Drickon · 2 months ago

Respectfully, the refresh-token-race / corrupted-.credentials.json theory can't be the mechanism in our case: we authenticate purely with a static sk-ant-oat01… env token that has no refresh token and is never written back to .credentials.json, yet we hit the identical 401. So there's nothing to race or corrupt on the credential side. Here's what we actually measured instead, and it points at the session identity, not the credential. (Notably #61912 itself captured the same token returning 200 on /oauth/hello and 401 on /v1/messages in the same second, unexpired, which fits a session-bound, probe-blind failure rather than a credential-file one.)

TL;DR: this 401 tracks the running instance's session, not the token, and the same token succeeds externally at the same instant. We run several headless Claude Code instances (v2.1.195) authed with a single static sk-ant-oat01… env token (claude setup-token, no refresh token, no .credentials.json). They recurrently start getting 401 Invalid authentication credentials / "Please run /login", and then hard-latch and never self-recover until the process is restarted.

**The token is fine the whole time. The session is what's rejected. During a live wedge we fired raw POST /v1/messages with the exact same static token, in many shapes (minimal, agent-shaped, large cache-creation, streaming, 12 tools, metadata, resumed-style). All returned 200 at the same instant the wedged instance's own turns returned 401.** Same token, account, IP, model, request shape, yet 200 externally and 401 from inside the wedged process. The rejection is tied to that instance's long-lived server-side session/process identity.

It's a hard client-side latch on a still-valid token. Across 412 sessions / 153 distinct 401 events: zero self-recovered without a restart. Even after the upstream rejection window closes, the instance stays latched. We're already on v2.1.195 (includes the v2.1.117 reactive-401-refresh and v2.1.178 stale-cached-config fixes) and it still latches, consistent with the cause being session-identity-bound, not token-bound, so re-minting/refreshing can't help.

Token probes are structurally blind here. Any "is the token valid?" check shares the token but not the wedged session identity, so it reads 200 throughout and tells you nothing. Verify recovery by an observed non-401 turn, never by a probe.

This isn't unique to our setup: anthropics/claude-code #61912 independently captured the same token returning 200 on /oauth/hello and 401 on /v1/messages in the same second, unexpired.

Separate, distinct issue (don't conflate): in one ~7h outage, direct probes showed Opus 4.8 and Sonnet 4.6 returning 429 rate_limit_error (generic body, x-should-retry: true, no retry-after) while Haiku returned 200 on the same token. The 5h cap was ~10% used, had reset ~3h earlier, and the 429 persisted through >5h idle, so this is not a usage cap. A naive Haiku probe reads 200 and misreports this big-model-tier throttle as "token fine."

Real request_ids (all 401 authentication_failed, token valid throughout, for tracing):

req_011CcVDWWs8GPfDyX8R9LEfW   2026-06-28 01:52 CDT
req_011CcVDW3MetrtoQqLU2m8cn   2026-06-28 01:52 CDT
req_011CcUaNDekFKPWogaeZ9adT   2026-06-27 17:45 CDT
req_011Cc3X8oSfApMWRCs66taQw   2026-06-14 12:10 CDT

Workaround we run: a watchdog that detects the 401 in the instance's logs, restarts the wedged instance, and verifies recovery by an observed non-401 turn (never a token probe), with a quiet-window backoff so it doesn't restart-storm into a still-open upstream window.

The one ask: the client latch should not survive the upstream window closing (it does, on v2.1.195). A session-identity 401 needs the client to re-establish session state, not just refresh the token. At minimum, surface an actionable error instead of a dead "Please run /login."

Full forensic write-up: full forensic write-up

lerugray · 2 months ago

Adding a macOS Keychain repro — still present in 2.1.195

This issue and its linked reports are all platform:linux / ~/.claude/.credentials.json, but the same failure occurs on the macOS Keychain credential backend.

Environment

  • Claude Code 2.1.195, macOS, Max subscription (not API-key billing)
  • Credential store: macOS Keychain item Claude Code-credentials (no .credentials.json on this platform)
  • Affected machine is a laptop that sleeps; the always-on machines on the same account are unaffected.

Natural A/B across a 4-machine fleet, one account:

  • 3 machines authenticate with a static sk-ant-oat01… setup-token via CLAUDE_CODE_OAUTH_TOKEN (no Keychain item, no refresh token): never forced to /login.
  • The 1 machine on the interactive Keychain OAuth login (rotating refresh token) is forced to /login repeatedly, frequently at session start.

This matches the earlier point in this thread that static-token sessions with no refresh token don't hit it — in this fleet the only affected machine is the one on the rotating Keychain grant. Pinning a static token in the env on that machine did not help while the Keychain item remained present (the env token governs inference only; the startup Keychain check still failed) — consistent with rejection/rotation at the credential-store level, not the env level. Happy to provide platform detail privately.

Showing cached comments. Read the full discussion on GitHub ↗