[BUG] OAuth refresh returns 400 after early 401 before local expiresAt; concurrent sessions forced to /login
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:
zshinsidetmux - Common command wrapper:
ccis only an alias forNODE_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, orCLAUDE_CODE_OAUTH_TOKENin a clean shell. - Running Claude Code process environments did not contain those auth override env vars.
ANTHROPIC_CUSTOM_HEADERSwas 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
- Are Claude Code OAuth refresh tokens rotating and single-use?
- If an older Claude Code process attempts to use a stale refresh token, can that invalidate the current refresh token family or account session?
- Does Claude Code 2.1.121 serialize refresh attempts across processes sharing one
~/.claude/.credentials.json? - Before attempting refresh after a 401, does Claude Code re-read disk and compare whether another process has already written newer credentials?
- 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.
Showing cached comments. Read the full discussion on GitHub ↗
14 Comments
Found 3 possible duplicate issues:
This issue will be automatically closed as a duplicate in 3 days.
🤖 Generated with Claude Code
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:
2.1.123, WSL2 (Ubuntu on Windows),bashObserved pattern:
Inspecting
~/.claude/.credentials.json, theexpiresAtafter/loginis consistently ~8 hours in the future (expiresAttimestamp confirms this). On next day's first launch, Claude Code prompts/loginagain — 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.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 Code2.1.123from the native install.Active Claude Code process at the time:
The debug log attribution line for this request shows:
Request / refresh sequence
Relevant debug-log sequence:
Important IDs:
Credential file state immediately after failure
Active credential file:
Claude Code's alternate auth path is just a symlink to the same file:
Sanitized credential metadata immediately after the failure:
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 futureexpiresAt, and an empty refresh token.The credential file mtime lines up with the failed refresh attempt:
Other local files touched in the same narrow window were only session/history/debug-log files:
No apparent local override / shadow credential source
The running Claude process environment had no active auth overrides:
The only Claude process running was this same long-running
~/.local/bin/claudeprocess. 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:
/v1/messages.401 authentication_erroreven though the localexpiresAtis still in the future.https://platform.claude.com/v1/oauth/token.400.refreshTokenlength0, while retaining a non-empty access token and futureexpiresAt.401and the CLI asks for/login.This is happening with OAuth / Claude Max subscription auth, not API key auth.
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.jsonat request time. The token endpoint rejected it with: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:
The request refresh-token hash and the active credential-file refresh-token hash are identical.
Captured token endpoint response
Claude Code debug log sequence
This was on Claude Code
2.1.126according to the debug log attribution line:Relevant sequence:
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:e3b0c442...is the SHA-256 of the empty string.Summary
The observed behavior is:
.credentials.jsoncontains a non-empty refresh token..credentials.json.https://platform.claude.com/v1/oauth/tokenreturns400 invalid_grant: Refresh token not found or invalid..credentials.jsonwithrefreshTokenlength0, while retaining a non-empty access token and futureexpiresAt.401 authentication_errorand require/login.This is OAuth / Claude Max subscription auth, not API-key auth.
Still reproducing on Claude Code 2.1.126 — Linux.
Still reproducing with Claude Code v2.1.126 on Ubuntu 24.04.
@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.
Same symptoms on WSL2 / 2.1.143 / Max plan — adding environment data point.
Setup:
rateLimitTier: default_claude_max_5x~/.claude/.credentials.json(mode 600), no keyring (WSL2 doesn't rungnome-keyring-daemon)Observed pattern:
/login~1x/day, sometimes multiple times/day/login/loginfrom any one session restores ALL sessions simultaneously — confirms shared-file invalidation, not per-session issueHaven't captured 400/401 logs as cleanly as OP, so can't independently confirm the exact
400from 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.
---
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. Accshell 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: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-tokenproduces a 1-yearCLAUDE_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:
/loginper slot the first time you use it (if you go the per-slot route)./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_DIRkeychain keying isn't widely documented and seemed worth surfacing.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-credentials) and~/.claude/.credentials.json(claudeAiOauth) — both actively writtenclaudeprocesses (agent-team--nameworkers + multiple interactive sessions + desktop app)/loginmultiple 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 orphanedClaude Code-credentials-<hash>Keychain entries (each forced re-login mints new ones), andnumStartups.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
mdatalso 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:
88bc…→aae9…→a93c…). Any process still holding a superseded token gets rejected, consistent with the 400 on refresh described here.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→/logincascade. Reducing concurrent sessions reduces frequency but doesn't eliminate it. Happy to share the (redacted) raw 5-min sample log if useful.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
claudeprocesses, macOS, Max plan). It refreshes~/.claude/.credentials.jsonevery 15 min, but only within 30 min ofexpiresAt.The refresher worked mechanically — two clean refreshes:
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
a93cin 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-invalidateda93c→invalid_grant→ forced/loginacross 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.
This looks like the classic concurrent refresh-token race: two processes share
~/.claude/.credentials.jsonand 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:
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.
Respectfully, the refresh-token-race / corrupted-
.credentials.jsontheory can't be the mechanism in our case: we authenticate purely with a staticsk-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/helloand 401 on/v1/messagesin 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 getting401 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/messageswith 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/helloand 401 on/v1/messagesin 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, noretry-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):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
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-credentials(no.credentials.jsonon this platform)Natural A/B across a 4-machine fleet, one account:
sk-ant-oat01…setup-token viaCLAUDE_CODE_OAUTH_TOKEN(no Keychain item, no refresh token): never forced to/login./loginrepeatedly, 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.