Frequent re-authentication required with multiple concurrent Claude Code sessions (OAuth refresh token race condition)

Resolved 💬 22 comments Opened Feb 9, 2026 by pfallonjensen Closed May 7, 2026

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

  1. Open 5+ terminal tabs, each running claude in different project directories
  2. Work normally across sessions throughout the day
  3. After some time (typically a few hours), one or more sessions will prompt: /login to re-authenticate
  4. 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:

  1. Process A's access token expires
  2. Process A uses the refresh token → gets new access token + new refresh token
  3. Process A writes new credentials to ~/.claude/.credentials.json
  4. Process B's access token also expires (or was already expired)
  5. Process B tries to use the old refresh token (which it read into memory at startup)
  6. Server rejects the old refresh token (already consumed by Process A)
  7. 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

  1. 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 expiresAt is in the future, use the new token instead of refreshing.
  1. 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.
  1. 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.
  1. 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.

View original on GitHub ↗

22 Comments

github-actions[bot] · 5 months ago

Found 3 possible duplicate issues:

  1. https://github.com/anthropics/claude-code/issues/22600
  2. https://github.com/anthropics/claude-code/issues/13448
  3. https://github.com/anthropics/claude-code/issues/3117

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

mieubrisse · 5 months ago

I'm hitting exactly this issue. Thank you for the forensics!

schoukah · 4 months ago

Same issue here. Thank you for documenting it.

sasha-computer · 4 months ago

This is a frustrating issue, I have the exact same issue.

mieubrisse · 4 months ago

I worked around this using claude setup-token and then feeding it in as the CLAUDE_CODE_OAUTH_TOKEN environment variable. It skips all the "OAuth tokens invalidating each other", but has the downside that it doesn't allow /usage

mieubrisse · 4 months ago

Has anyone tested with v2.1.44? Supposedly:

Fixed auth refresh errors
sstklen · 4 months ago

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.admin did not satisfy operator.* wildcard scope checks in device-auth, causing repeated pairing/auth churn on every CLI invocation. (2) Pairing approval flow omitting scopes field would rotate tokens to empty scope sets, effectively wiping device-auth.json tokens back to {} on each command.

What worked for us:

Two fixes: (1) Make operator.admin satisfy operator.* wildcard scope checks so admin-scoped devices don't trigger re-pairing. (2) Prevent pairing approvals that omit scopes from rotating tokens to empty scope sets — preserve the approved scope baseline for existing device tokens.

Steps:

  1. In device-auth scope checking logic, implement proper wildcard matching so operator.admin satisfies operator.* checks (commit 55d492b4c)
  2. In pairing approval handler, guard against missing `scopes
// Fix 1: Wildcard scope matching (scope-check.ts)
// Before: exact match only
// if (requiredScope === deviceScope) return true;
// After: support wildcard matching
function scopeSatisfies(deviceScope: string, requiredScope: string): boolean {
  if (deviceScope === requiredScope) return true;
  // operator.admin satisfies operator.*
  const [dNs, dPerm] = deviceScope.split('.');
  const [rNs, rPerm] = requiredScope.split('.');
  if (dNs === rNs && rPerm === '*') return true;
  return false;
}

// Fix 2: Prevent empty-scope rotation (pairing-approval.ts)
// Before: unconditionally rotated token with new scopes
// device.token = rotateToken(device, approval.scopes);
// After: preserve existing scopes if approval omits them
if (approval.scopes && approval.scopes.length > 0) {
  device.token = rotateToken(device, approval.scopes);
} else {
  // Preserve existing approved scope baseline, do not rotate
  device.token = refreshToken(device, device.scopes);
}

Hope this helps! Let me know if it doesn't match your case — happy to dig deeper. 🦞

_Disclosure: This analysis is from Confucius Debug, an AI-powered community KB for agent bugs. Please verify before applying._

---
<sub>🦞 Confucius Debug — community knowledge base for AI agent bugs. Free to search via MCP.</sub>

garimto81 · 4 months ago

Reproducing with 8 concurrent VS Code sessions on Windows

Environment:

  • OS: Windows 11 Enterprise
  • Claude Code: v2.1.62
  • Subscription: Max (20x tier)
  • Setup: Up to 8 VS Code windows, each running its own Claude Code session simultaneously

Symptoms:

  • Every time a new VS Code window launches Claude Code, a browser OAuth login popup appears
  • Previously (older CLI versions), a single login persisted across all sessions
  • The issue is consistent and reproducible — not intermittent

Impact:

  • With 8 concurrent sessions, the re-authentication friction is severe
  • Breaks the multi-project workflow where each VS Code window targets a different repository
  • Sometimes re-authentication is required even mid-session when another window triggers a token refresh

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.

unwashedlugosi · 4 months ago

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.

golproductions · 4 months ago

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.

  • Introducing GOL V1.1
kokuyouwind · 4 months ago

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_TOKEN approach mentioned in this thread — exporting a token via claude setup-token so 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_KEY works 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_TOKEN was filling.

LARIkoz · 3 months ago

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 -p calls (10 concurrent × 80 waves) from Cursor Bash tool → Extension kicks out after ~60 calls.

I reverse-engineered cli.js v2.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 use proper-lockfile on ~/.claude/ directory:

  1. Check .credentials.json mtime → invalidate caches if changed
  2. Acquire dir-based lock (proper-lockfile) — 5 retries, 1000+random()*1000 ms delay
  3. After lock: re-read Keychain. If token now valid → emit tengu_oauth_token_refresh_race_resolved, skip refresh
  4. POST https://platform.claude.com/v1/oauth/token with grant_type: refresh_token
  5. Save new tokens, release lock

The 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_reached and 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 if accessToken changed, but this adds latency and failed requests.

Server-Side Token Lifetime Override

setup-token sends expiresIn: 31536000 (1 year) to the server. The server returns ~8 hours. This means setup-token cannot 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:

  • TTY vs non-TTY: Terminal.app provides a pseudo-terminal; securityd can prompt for Keychain unlock. Cursor spawns non-interactive subprocesses → errSecInteractionNotAllowed on Keychain prompts
  • Process tree depth: Terminal.app → bash → claude (2 levels). Cursor → Electron → Node.js → PTY Host → bash → claude (5 levels) — deeper tree may affect Keychain ACL resolution
  • Electron entitlements: Cursor's Electron bundle has restricted com.apple.security.keychain-access-groups, while Terminal processes inherit full user Keychain access

Proposed Fixes (from analysis)

Quick wins (client-side):

  1. Increase lock retry budget — 5 retries is too few for 10+ processes. Suggest 15-20 retries or exponential backoff up to 30s
  2. Reduce Keychain cache TTL — 5s is too long for high-concurrency. 500ms or invalidate-on-write would eliminate stale reads
  3. Add --no-refresh flag for claude -p — when token is valid, skip refresh entirely. For batch jobs where token was pre-warmed

Architectural (longer-term):

  1. Token broker pattern — single daemon process owns Keychain writes, serves tokens via Unix socket. This is how gcloud solved the same problem
  2. SQLite-based credential store with WAL — proper concurrent read/write without file locks (proposed in #29077, closed without merge)
  3. Per-process token isolation — each claude -p gets its own short-lived token derived from the main credential, no shared Keychain writes

Related Open Issues (all post-v2.1.81)

  • #37203 — Background agents trigger 'Not logged in'
  • #37324 — Session token expires within minutes with concurrent subagents
  • #37468 — 5 parallel agents, 3 hours of work lost
  • #37402 — --print mode token not persisted/refreshed

All confirm: the v2.1.81 session-level fix does not cover parallel subprocess/subagent scenarios.

LARIkoz · 3 months ago

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 -p in detached tmux sessions instead of directly from Cursor/VS Code Bash tool.

tmux new-session -d -s "worker-$id" "claude -p '...' --model sonnet > output.log"

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.

LARIkoz · 3 months ago

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:

  1. Pre-batch: force refresh → fresh 8h token
  2. Token gate: require 2h minimum before starting
  3. tmux: insurance for Extension isolation
  4. Result: batch completes within token lifetime, refresh never triggered, race impossible

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 gcloud uses.

LARIkoz · 3 months ago

Community workaround available: claude-batch — tmux-isolated parallel claude -p that prevents auth kickout. Drop-in replacement. See also RFC: #37678

graid2030 · 3 months ago

We're seeing the same issue on Linux/Kubernetes with claude remote-control: a pod can still look logged in via claude auth status, but the OAuth accessToken in ~/.claude/.credentials.json is already expired, so the remote host appears alive yet stops responding until someone manually re-runs claude auth login.

Given that remote-control seems 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 for remote-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.

ssj5037 · 3 months ago

+1 — reproducing this reliably on v2.1.97 (which already includes the proper-lockfile retry fix from v2.1.81). The 5-retry / ~7.5s budget is not enough once you go past ~3 concurrent sessions.

Environment

  • Claude Code: v2.1.97 (native installer, ~/.local/share/claude/versions/2.1.97)
  • macOS: Darwin 25.4.0 (Apple Silicon)
  • Plan: Claude Max
  • Multiplexer: cmux — spawns each surface as an independent claude process (the wrapper only injects --session-id and --settings; it does not touch HOME or CLAUDE_CONFIG_DIR, so all surfaces share the same credential store)
  • Credential store confirmed: macOS Keychain item \Claude Code-credentials\ + \oauthAccount\ in \~/.claude.json\

Repro

  1. Open ~6 cmux surfaces, each runs \claude\ → 6 independent Claude Code processes sharing the single Keychain credential.
  2. Work normally across all 6 for ~10–30 min.
  3. One surface suddenly prompts for \/login\. After re-authenticating, a different surface (previously healthy) prompts for \/login\ on its next request. Ping-ponging continues indefinitely.
  4. User never issues \/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

  • Single-use rotating refresh token: process A refreshes → writes new tokens → processes B–F still hold the old refresh token in-process → next refresh attempt returns 401 → forced re-login.
  • The v2.1.81 lockfile only serializes writes to the credential store; it does not invalidate in-memory token caches of sibling processes, so the race is not actually eliminated at 6 sessions.

What would actually fix this

Any of:

  • A broker/daemon process that owns refresh and hands short-lived access tokens to child \claude\ processes (single source of truth for refresh).
  • In-process invalidation notification (e.g. file-watcher on the credentials file) so sibling processes reload after a rotation instead of trying to refresh with a stale token.
  • Switch to non-rotating refresh tokens for the Claude Code OAuth client (removes the race entirely; trade-off is refresh token longevity).

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.
  • Staggering launches — refreshes happen on a token-expiry timer, not on launch, so staggering doesn't prevent later collisions.

Happy to capture a \~/.claude/logs\ bundle from a live repro if that would help — just let me know what to grab.

attractify-logan · 2 months ago

Adding a Linux repro with a deterministic fingerprint that might help narrow the corruption write-path.

Setup: 2× concurrent claude processes 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):

  1. Steady state: ~/.claude/.credentials.json is 854 bytes (accessToken + refreshToken + scopes + rateLimitTier + subscriptionType, plus an mcpOAuth block).
  2. IN-PLACE-MODIFY: same inode, size drops to 472 bytes, claudeAiOauth.expiresAt rewound 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 the refreshToken.
  3. A few minutes later: INODE-CHANGED (unlink+recreate) → full OAuth re-login, fresh 8-hour token, new inode, size 471 → 854 after the refresh-token writeback.

Supporting detail: both racing claude processes 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 expiresAt fingerprint 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 duplicate of 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 the refreshToken field.

Environment: claude --version → 2.1.114, Debian 13, subscription tier max. Happy to share the full watcher log (format: inode, size, mtime, parsed claudeAiOauth.expiresAt per tick) if that would help.

Mitigations tried locally: none effective for bare claude processes. We had a separate upstream leak via @agentclientprotocol/claude-agent-acp chains (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.

mgajda · 2 months ago

Still reproducing on Claude Code 2.1.126 — Linux.

chid · 2 months ago

Is this fixed? Is the expected behaviour that Claude is meant for single active session?

Houtamelo · 2 months ago

Could this issue be re-opened? It is certainly not fixed as many others have pointed out.

Alandougherty · 1 month ago

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