RFC: Prevent parallel auth kickout via token refresh serialization

Resolved 💬 2 comments Opened Mar 23, 2026 by LARIkoz Closed Apr 21, 2026

Summary

Parallel claude -p processes cause VS Code/Cursor extension to lose authentication. This has been reported across 7+ issues (#24317, #37512, #37203, #37324, #37468, #25609, #22600) and affects every user running concurrent sessions on macOS.

The v2.1.81 fix addressed session-level races but not subprocess/subagent scenarios. RFC #29077 proposed 6 comprehensive fixes but was closed without merge.

Root Cause (from cli.js v2.1.81 source analysis)

  1. Single Keychain entry — all processes share Claude Code-credentials
  2. Rotating refresh tokens — server invalidates old token on refresh
  3. proper-lockfile — 5 retries × ~1.5s = 7.5s budget, insufficient for 10+ processes
  4. 5-second cache TTL — in-memory Keychain cache causes stale reads between processes
  5. No refresh dedup across processes — in-process Promise dedup exists, but cross-process = only filesystem lock

Proposed Fixes (prioritized)

Fix 1: Increase lock budget (minimal change)

// Current: 5 retries, 1-2s delay = ~7.5s max wait
// Proposed: 20 retries, 1-3s delay = ~40s max wait
Tu7.lock(configDir, { retries: 20, delay: 1000 + Math.random() * 2000 })

Impact: Reduces lock contention failures. Won't fix the root cause but buys time.

Fix 2: Read-after-lock optimization (medium change)

After acquiring lock, the CLI already re-reads Keychain. If another process refreshed during the wait, it emits tengu_oauth_token_refresh_race_resolved and returns. This works correctly. The problem is processes that fail to acquire the lock — they should also re-read Keychain before giving up, because the lock holder likely already refreshed.

// On ELOCKED after max retries:
clearAllCaches();
const fresh = await readFromKeychain();
if (fresh && !isExpired(fresh.expiresAt)) {
  emit('tengu_oauth_token_refresh_lock_timeout_recovered');
  return fresh; // Use the token another process refreshed
}
// Only fail if token is truly expired AND lock unavailable

Fix 3: CLAUDE_CODE_OAUTH_TOKEN should be read-only (bug fix)

Currently, setting this env var causes the CLI to delete Keychain credentials on exit (#37512). The env var should make the process entirely read-only with respect to credential storage:

if (process.env.CLAUDE_CODE_OAUTH_TOKEN) {
  // Skip ALL credential writes, deletes, and cleanup
  return;
}

Fix 4: Non-rotating refresh tokens (server-side)

If the OAuth server issued non-rotating refresh tokens (old refresh token remains valid after use), the race condition becomes harmless — multiple processes can refresh without invalidating each other. Google OAuth supports this via configuration.

Fix 5: Token broker pattern (architectural)

A single process (or launchd agent) owns Keychain access and serves tokens via IPC. All CLI instances request tokens from the broker. This is how gcloud solved the same problem.

Fix 6: Reduce Keychain cache TTL

The 5-second in-memory cache (UI7=5000) means a freshly-refreshed token takes up to 5s to be visible to other processes. Reducing to 500ms would narrow the stale-read window significantly.

Community Workaround

We built claude-batch — a tmux-based wrapper that prevents refresh from occurring during batch runs. Strategy: pre-batch force refresh → 2h token gate → batch completes within token lifetime → no refresh triggered → no race.

Validated by 7-model consilium (Gemini, Grok-4, DeepSeek, Mistral, Codex, Qwen, Claude Opus), 3 rounds code review, 1 red team. 6/6 APPROVE.

References

  • #24317 — Primary tracking issue
  • #37512 — CLAUDE_CODE_OAUTH_TOKEN deletion bug
  • #29077 — Previous RFC (closed without merge)
  • Source: _P1() (refresh), NA9() (401 recovery), FI7 (fallback combiner), UI7=5000 (cache TTL)

View original on GitHub ↗

This issue has 2 comments on GitHub. Read the full discussion on GitHub ↗