Frequent re-authentication required with multiple concurrent Claude Code sessions (OAuth refresh token race condition)
Bug: Frequent re-authentication required with multiple concurrent Claude Code sessions
Description
Claude Code requires re-authentication (browser OAuth flow) multiple times per day, even though a valid refresh token exists in ~/.claude/.credentials.json. This appears to be caused by a race condition when multiple concurrent Claude Code sessions attempt to refresh the same OAuth token.
Environment
- Claude Code version: 2.1.37
- OS: macOS 15.7.1 (Apple Silicon, arm64)
- Subscription: Team (Max 5x tier)
- Concurrent sessions: 7-12 active Claude Code processes at any given time
Steps to Reproduce
- Open 5+ terminal tabs, each running
claudein different project directories - Work normally across sessions throughout the day
- After some time (typically a few hours), one or more sessions will prompt:
/loginto re-authenticate - Re-authenticating in one session may cause other sessions to lose their auth shortly after
Expected Behavior
The refresh token should silently renew the access token without user interaction. Multiple concurrent sessions sharing the same credentials file should coordinate token refresh (e.g., file-based locking, or re-reading the credentials file before attempting refresh).
Actual Behavior
Sessions frequently lose authentication and require a full browser-based OAuth re-login. This happens multiple times per day with several concurrent sessions open.
Root Cause Analysis
The credentials file (~/.claude/.credentials.json) contains:
accessToken— short-lived (~15 hour expiry)refreshToken— used to obtain new access tokens
OAuth refresh tokens are typically single-use: when one process uses the refresh token to get a new access/refresh token pair, the old refresh token is invalidated server-side. With N concurrent Claude Code processes:
- Process A's access token expires
- Process A uses the refresh token → gets new access token + new refresh token
- Process A writes new credentials to
~/.claude/.credentials.json - Process B's access token also expires (or was already expired)
- Process B tries to use the old refresh token (which it read into memory at startup)
- Server rejects the old refresh token (already consumed by Process A)
- Process B prompts user to re-authenticate via browser
This race becomes increasingly likely with more concurrent sessions, as the window for token expiry overlap grows.
Impact
- Disrupts workflow — interrupts active coding sessions to complete browser OAuth flow
- Blocks headless/SSH use cases entirely — Claude Code cannot be used over SSH when re-auth is needed, because the OAuth flow opens a browser on the host machine. This makes remote access (e.g., from a mobile SSH client) unreliable.
- Scales with usage — power users who keep multiple sessions open (different projects, different terminal tabs) are most affected
Suggested Fixes
- Re-read credentials from disk before refresh: Before attempting a token refresh, check if another process has already written a fresh token to the credentials file. If the file's
expiresAtis in the future, use the new token instead of refreshing.
- File-based locking on refresh: Use a lockfile (
~/.claude/.credentials.lock) to prevent concurrent refresh attempts. Only one process refreshes at a time; others wait and then read the refreshed token.
- Longer-lived refresh tokens: If the refresh token had a longer lifetime or was multi-use (not invalidated on first use), concurrent sessions could independently refresh without invalidating each other.
- In-memory token sharing: Use a local IPC mechanism (Unix socket, shared memory) so all Claude Code processes share a single token manager that handles refresh.
Workaround
Currently, the only workaround is to close stale Claude Code sessions to reduce the number of concurrent processes competing for token refresh. This is not ideal for users who work across multiple projects simultaneously.
An ANTHROPIC_API_KEY environment variable bypasses OAuth entirely but uses API billing instead of the subscription — not a real fix for subscription users.
Additional Context
This bug is particularly impactful for users building automation around Claude Code (e.g., LaunchAgent-triggered sessions, SSH-based remote access from mobile devices) where browser-based re-authentication is impossible.
22 Comments
Found 3 possible duplicate issues:
This issue will be automatically closed as a duplicate in 3 days.
🤖 Generated with Claude Code
I'm hitting exactly this issue. Thank you for the forensics!
Same issue here. Thank you for documenting it.
This is a frustrating issue, I have the exact same issue.
I worked around this using
claude setup-tokenand then feeding it in as theCLAUDE_CODE_OAUTH_TOKENenvironment variable. It skips all the "OAuth tokens invalidating each other", but has the downside that it doesn't allow/usageHas anyone tested with v2.1.44? Supposedly:
Hey! I ran into a similar pattern in our bug knowledge base and thought this might help.
What's happening: Token-scope regression: (1)
operator.admindid not satisfyoperator.*wildcard scope checks in device-auth, causing repeated pairing/auth churn on every CLI invocation. (2) Pairing approval flow omittingscopesfield would rotate tokens to empty scope sets, effectively wipingdevice-auth.jsontokens back to{}on each command.What worked for us:
Two fixes: (1) Make
operator.adminsatisfyoperator.*wildcard scope checks so admin-scoped devices don't trigger re-pairing. (2) Prevent pairing approvals that omitscopesfrom rotating tokens to empty scope sets — preserve the approved scope baseline for existing device tokens.Steps:
operator.adminsatisfiesoperator.*checks (commit 55d492b4c)Hope this helps! Let me know if it doesn't match your case — happy to dig deeper. 🦞
---
<sub>🦞 Confucius Debug — community knowledge base for AI agent bugs. Free to search via MCP.</sub>
Reproducing with 8 concurrent VS Code sessions on Windows
Environment:
Symptoms:
Impact:
Analysis:
All 8 processes share
~/.claude/.credentials.json. When one process refreshes the single-use OAuth token, the remaining 7 processes hold an invalidated refresh token and must re-authenticate via browser.A file-level lock or credentials re-read before refresh (as suggested in this issue) would resolve this for multi-window VS Code workflows.
I'm hitting this constantly on macOS. I run 2-3 concurrent Claude Code sessions via Remote Control and get logged out multiple times per day — sometimes within minutes of logging back in.
Version 2.1.63, macOS 15.4, Max plan.
The multi-session race condition described here matches my experience exactly. Happy to provide logs or other details if helpful.
Damn, okay... I see exactly what's going on here. It's frustrating, but the real reason this is happening is a perfect storm of modern OAuth 2.0 security practices colliding with your multi-session power-user setup. Specifically, those refresh tokens are one-time-use, meaning the moment Process A uses the current refresh token to get new credentials and a new refresh token, the old refresh token (which Process B and all your other tabs are still holding in memory) is instantly made useless by the auth server.
Here's the engineering physics: when multiple sessions are running, they all read the credentials at startup. Let's say session 1, 2, and 3 all have the same initial refresh token, R0. Then session 1's access token expires. It presents R0, gets a fresh access token, and gets R1 (a new refresh token). It writes this back to disk. Then session 2 or 3, both still holding R0 in memory, eventually expire their access tokens and present the (now revoked) R0. The server rejects it, assumes someone stole it, and makes you log in from scratch in a browser.
To win next time, you must implement atomic, coordinated access to the token storage. I'm talking about implementing file locking (using something like fcntl in Python, or equivalent mechanism for Claude Code's language) around the entire refresh flow. Your sessions need to coordinate:
A session wanting to refresh must first acquire a system-wide lock (e.g., on a credentials.lock file). This ensures mutual exclusion.
Before it even considers refreshing, it must read the credentials file again. It needs to check if another session already refreshed the token. If the token on disk is already fresh (i.e., its expiresAt is in the future), the current session should just use that new token and not refresh.
If the token on disk is still expired, then it proceeds to call the OAuth server, gets the new token pair, and writes it to disk.
Only after writing to disk does it release the lock.
This makes the refresh process atomic and serializes all requests. Your concurrent sessions will effectively wait in line, and the first one in line does the work while the others just reap the benefits. It's the only way to avoid that race condition when dealing with single-use refresh tokens.
I've been hitting this same issue and wanted to share additional context that might be useful.
As a workaround, I had been using the
CLAUDE_CODE_OAUTH_TOKENapproach mentioned in this thread — exporting a token viaclaude setup-tokenso each concurrent session could start with a pre-authenticated state, bypassing the browser re-auth loop entirely. This worked reliably until the server-side enforcement in February 2026 blocked OAuth tokens from being used outside the official Claude Code interactive flow.I understand the policy reasoning, but the effect for legitimate multi-session users is that the only practical workaround for this bug is now gone. The recent CHANGELOG entries mention fixes for "a race condition where stale OAuth tokens could be read from the keychain cache during concurrent token refresh attempts" — but the underlying issue of sessions losing auth when running multiple concurrent instances still persists in practice.
For users running several terminal sessions across different projects simultaneously, there's currently no stable path that stays within the subscription. Switching to
ANTHROPIC_API_KEYworks technically but moves to per-token API billing, which defeats the purpose of a Max/Pro plan.A proper fix (file lock, IPC token sharing, or similar) would be the right solution. Alternatively, an officially supported way to pre-authenticate concurrent sessions within the subscription scope would address both this bug and the use case that
CLAUDE_CODE_OAUTH_TOKENwas filling.Deep Source Code Analysis — Why v2.1.81 Fix is Incomplete
Environment: macOS Darwin 25.3.0, Claude Code v2.1.81, Cursor (VS Code fork), Max plan.
Reproduction: 800+ parallel
claude -pcalls (10 concurrent × 80 waves) from Cursor Bash tool → Extension kicks out after ~60 calls.I reverse-engineered
cli.jsv2.1.81 to understand the exact auth mechanism. Here's what I found:The Lock Exists — But Has Gaps
The v2.1.81 refresh flow (
_P1()) does useproper-lockfileon~/.claude/directory:.credentials.jsonmtime → invalidate caches if changedproper-lockfile) — 5 retries, 1000+random()*1000 ms delaytengu_oauth_token_refresh_race_resolved, skip refreshhttps://platform.claude.com/v1/oauth/tokenwithgrant_type: refresh_tokenThe problem: With 10+ concurrent processes, the lock retry budget (5 attempts × ~1.5s = ~7.5s max wait) is insufficient. If a refresh takes >7.5s (network latency, server load), remaining processes hit
tengu_oauth_token_refresh_lock_retry_limit_reachedand fail permanently.5-Second Keychain Cache Creates a Stale Window
Keychain reads are cached in-memory with 5-second TTL (
UI7=5000). Process A refreshes at T=0, Process B reads at T=0.5 with a cache populated at T=-4 → gets stale token → 401. The 401 handler (NA9()) does self-heal by re-reading Keychain and checking ifaccessTokenchanged, but this adds latency and failed requests.Server-Side Token Lifetime Override
setup-tokensendsexpiresIn: 31536000(1 year) to the server. The server returns ~8 hours. This meanssetup-tokencannot be used as a reliable workaround for long-running batches (contrary to documentation suggesting ~1 year).Why Terminal.app Works but VS Code/Cursor Doesn't
Tested: identical 48-call batch from Terminal.app → 0 kickouts. Same batch from Cursor Bash → kicked out.
Likely causes:
securitydcan prompt for Keychain unlock. Cursor spawns non-interactive subprocesses →errSecInteractionNotAllowedon Keychain promptscom.apple.security.keychain-access-groups, while Terminal processes inherit full user Keychain accessProposed Fixes (from analysis)
Quick wins (client-side):
--no-refreshflag forclaude -p— when token is valid, skip refresh entirely. For batch jobs where token was pre-warmedArchitectural (longer-term):
gcloudsolved the same problemclaude -pgets its own short-lived token derived from the main credential, no shared Keychain writesRelated Open Issues (all post-v2.1.81)
--printmode token not persisted/refreshedAll confirm: the v2.1.81 session-level fix does not cover parallel subprocess/subagent scenarios.
Update: tmux isolation = complete workaround
Following up on my earlier source code analysis:
Our PATH shim (blocking
security delete-generic-password) prevented Keychain deletion but did NOT prevent the refresh token race. Extension was still kicked out after ~400 parallel calls from Cursor Bash.What actually works: Running
claude -pin detached tmux sessions instead of directly from Cursor/VS Code Bash tool.tmux server is a separate process tree (not child of Electron/Cursor), providing the same isolation as Terminal.app. 30 parallel calls, Extension never kicked out.
Key: do NOT set
CLAUDE_CODE_OAUTH_TOKEN— let tmux-hosted processes authenticate naturally. The race condition only affects processes within Cursor's process tree.Wrapper with retry, concurrency cap, and cleanup available at our repo if anyone needs it.
Update 2: Root cause clarified — it's NOT just process isolation
After extensive testing (800+ calls, 5 red team agents), we found that tmux isolation alone doesn't prevent the race. Workers still share Keychain.
The real fix: prevent token refresh from happening at all during batch.
Terminal.app's 48/48 success was because the token was fresh (8h) and the batch completed in 40 min — no worker ever attempted refresh.
Working strategy:
This is a client-side workaround. The real fix should be in the CLI: proper mutex on Keychain writes, or a token broker pattern like
gclouduses.Community workaround available: claude-batch — tmux-isolated parallel
claude -pthat prevents auth kickout. Drop-in replacement. See also RFC: #37678We're seeing the same issue on Linux/Kubernetes with
claude remote-control: a pod can still look logged in viaclaude auth status, but the OAuthaccessTokenin~/.claude/.credentials.jsonis already expired, so the remote host appears alive yet stops responding until someone manually re-runsclaude auth login.Given that
remote-controlseems intended for remote/headless workflows, how does Anthropic expect users to replace OpenClaw-style setups while this remains unresolved? Is there an official plan for a real cross-process refresh strategy / token broker, a daemon-safe auth model forremote-control, or a supported subscription auth mode for long-lived concurrent remote sessions?Right now this looks like a serious reliability gap for remote use.
+1 — reproducing this reliably on v2.1.97 (which already includes the
proper-lockfileretry fix from v2.1.81). The 5-retry / ~7.5s budget is not enough once you go past ~3 concurrent sessions.Environment
~/.local/share/claude/versions/2.1.97)cmux— spawns each surface as an independentclaudeprocess (the wrapper only injects--session-idand--settings; it does not touchHOMEorCLAUDE_CONFIG_DIR, so all surfaces share the same credential store)Claude Code-credentials\+ \oauthAccount\in \~/.claude.json\Repro
claude\→ 6 independent Claude Code processes sharing the single Keychain credential./login\. After re-authenticating, a different surface (previously healthy) prompts for \/login\on its next request. Ping-ponging continues indefinitely./logout\manually. Keychain item is never removed — it is being rewritten with a rotated token that sibling processes do not see in memory.Symptom pattern matches exactly
What would actually fix this
Any of:
claude\processes (single source of truth for refresh).Workarounds that do NOT work for Max users
claude setup-token\+ \CLAUDE_CODE_OAUTH_TOKEN\— server-side blocked for non-interactive use since ~Feb 2026.ANTHROPIC_API_KEY\— works, but moves billing off Max.Happy to capture a \
~/.claude/logs\bundle from a live repro if that would help — just let me know what to grab.Adding a Linux repro with a deterministic fingerprint that might help narrow the corruption write-path.
Setup: 2× concurrent
claudeprocesses on the same machine (both children of SSH sessions, same user, same~/.claude/.credentials.json). No MCP-OAuth activity during the repro — this is plain OAuth token refresh racing.Symptom sequence (captured by an inotify-style watcher polling the file every 20s):
~/.claude/.credentials.jsonis 854 bytes (accessToken+refreshToken+scopes+rateLimitTier+subscriptionType, plus anmcpOAuthblock).claudeAiOauth.expiresAtrewound to a deterministic stale value:2026-04-09T00:29:30Z. Same byte-identical value in 9/9 corruption events over 3+ days, across many process generations. The 472-byte file is missing therefreshToken.Supporting detail: both racing
claudeprocesses had the pre-corruption inode open as a deleted FD (/proc/$pid/fd/N -> .../.credentials.json (deleted)), confirming each holds its own in-memory OAuth snapshot from startup and that one of them flushes its stale view over the live file on refresh.Why the stale
expiresAtfingerprint is the useful new data point: if the value were different every corruption it would look like generic RMW race. Byte-identical across 9 events and multiple process lifetimes suggests the write path is either (a) falling back to a baked-in default when the in-memory state is incomplete, or (b) the 5-second keychain cache / Gate-2 skip discussed in #37678 / #50412 is re-emitting an old value from disk verbatim when the in-memory record gets cleared. The determinism makes it cheap to grep for in internal logs.Linux-specific, cross-linking the Linux duplicate: #43392 (OPEN, Linux, marked
duplicateof this issue) reports the same "wipe credentials.json" symptom on our platform. The 854→472 truncation is exactly the shrink you get when the JSON is serialized back without therefreshTokenfield.Environment:
claude --version→ 2.1.114, Debian 13, subscription tiermax. Happy to share the full watcher log (format: inode, size, mtime, parsedclaudeAiOauth.expiresAtper tick) if that would help.Mitigations tried locally: none effective for bare
claudeprocesses. We had a separate upstream leak via@agentclientprotocol/claude-agent-acpchains (bridged via OpenACP,Open-ACP/OpenACP#71/#73) that we mitigated with a reaper cron, but that class of leak is orthogonal to the plain two-claude-processes race described here.Still reproducing on Claude Code 2.1.126 — Linux.
Is this fixed? Is the expected behaviour that Claude is meant for single active session?
Could this issue be re-opened? It is certainly not fixed as many others have pointed out.
I ran into this same problem over mosh and built a small workaround: a LaunchAgent that refreshes ~/.claude/.credentials.json from the refresh token, bypassing the keychain. Published it here in case it helps anyone:
https://github.com/Alandougherty/claude-code-headless-macos