[BUG] claudeAiOauth wiped from Keychain (tokens blanked, expiresAt:0) when concurrent Desktop sessions race the single-use refresh token — refresh failure clobbers the winner's rotated credential
Preflight Checklist
- [x] I have searched existing issues — #84331 / #83345 / #83834 report the same corruption signature but without the cause; #43392 reports the same race but was fixed only for MCP credentials (v2.1.136). This report adds direct evidence of what writes the blank credential and when.
- [x] This is a single bug report
- [x] I am using the latest version of Claude Code
Environment
- macOS (Darwin 25.5.0, arm64), credentials in Keychain item
Claude Code-credentials(default config dir, single account) - Claude Desktop app with bundled engine 2.1.229 (
~/Library/Application Support/Claude/claude-code/2.1.229/), typically 5+ long-lived parallel Code sessions - Standalone CLI 2.1.233 (native install) also present, but not running during the captured incident
- No
ANTHROPIC_API_KEY/ANTHROPIC_AUTH_TOKEN/CLAUDE_CODE_OAUTH_TOKEN; no third-party credential tools running (verified below)
Summary
Every 1–3 days, all local sessions get logged out at once. The stored credential is not merely stale — the Keychain blob is overwritten with an emptied state:
"claudeAiOauth": {
"accessToken": "", // blanked
"refreshToken": "", // blanked — destroyed, cannot recover without interactive /login
"expiresAt": 0,
"refreshTokenExpiresAt": 1789311384942, // intact, ~4 weeks in the future
"subscriptionType": "max", ... // all non-secret fields intact
}
Every new process then fails with Failed to authenticate: OAuth session expired and could not be refreshed and only a full interactive re-login recovers.
Evidence: the wipe captured live
After the first incident I left a watcher polling the Keychain item every 20s (recording only token lengths, never values). It captured 3 days of healthy behavior and then the wipe itself:
[08-17 08:57] re-login -> AT=108 RT=108 expiresAt future
[08-17 16:55] rotation OK -> new expiresAt (mdat updated)
[08-18 00:51] rotation OK
[08-18 08:46] rotation OK
[08-18 16:45] rotation OK
[08-19 00:45] rotation OK
[08-19 08:41] rotation OK
[08-19 16:40] rotation OK
[08-20 00:40] rotation OK <- 8 clean rotations
[08-20 08:51:03] access token expired; refreshed OK 40s later
[08-20 16:52:31] access token expired; refreshed OK ~1h later
[08-21 01:30:21] access token expired (per expiresAt)
[08-21 01:57:26] watcher confirms expired state (machine likely asleep 01:30–01:57)
[08-21 02:37:04] WIPE: AT len 108, RT len 108 -> AT "" RT "" expiresAt 0
(Keychain mdat = 2026-08-21T09:37:04Z)
(times are UTC-7)
Process list at the wipe moment (captured 14s after the mdat): the only Claude-engine processes running were five Desktop-bundled 2.1.229 engines (long-lived parallel Code sessions, uptimes 22h–1.5d). Zero standalone CLI processes, zero third-party tools. ~/.claude/projects / session-env show no other session artifacts in the 02:30–02:45 window, ruling out short-lived -p invocations.
A prior incident on 08-17 had the identical signature (blob rewritten with blanked tokens, expiresAt: 0, non-secret fields intact, mdat updated at the moment of failure).
Analysis
This looks like two combined behaviors:
- No cross-process coordination for
claudeAiOauthrefresh. Multiple sessions sharing one Keychain item read the same single-use refresh token and race the rotation. v2.1.136 fixed exactly this for MCP credentials ("single cross-process lock with a fresh read under the lock", #43392) — the claude.ai login credential apparently doesn't go through that lock. - Clobber-on-failure. The loser of the race gets
invalid_grantand persists its failed (emptied) state, overwriting the winner's freshly rotated token. A failed refresh destroys a valid credential that another process just wrote.
The trigger pattern fits wake-from-sleep: all parallel sessions see an already-expired access token simultaneously and fire concurrent refreshes. Ordinary staggered rotations (8 in a row above) survive fine because only one session refreshes at a time.
This also explains third-party reports with the same signature (e.g. pingdotgg/t3code#6022, where concurrent multi-instance spawning triggers it reliably on every update): the concurrency source differs, but the wipe is Claude Code's own write.
Expected behavior
- A refresh that fails with
invalid_grantshould never overwrite the stored credential — re-read the store first (another process has likely just rotated it) and retry with the fresh token, or leave the store untouched. claudeAiOauthrefresh should go through the same cross-process lock that MCP credential writes use since v2.1.136.
Steps to reproduce (probabilistic)
- macOS, claude.ai subscription login, several long-lived Desktop Code sessions in parallel.
- Let the access token expire while the machine sleeps.
- Wake; sessions resume concurrently.
- Within days, the Keychain blob is rewritten with blanked tokens /
expiresAt: 0and every surface demands re-login despite a refresh token that was valid for weeks.
Happy to provide the full watcher log (timestamps + token lengths + process lists only, no secrets) on request.
Showing cached comments. Read the full discussion on GitHub ↗
4 Comments
I had the same problem, but I was able to last week, starting last Friday.
Seeing the same signature under a different concurrency source: Supacode spawns many parallel
claudeCLI sessions (one per git worktree pane, all under onevictorstortiaccount), each going throughlogin -flp→zsh, all sharing the same Keychain item /~/.claudecredential store — architecturally the same shape as the 5 parallel Desktop-bundled engines in the OP, just via terminal panes instead of Desktop windows.Reproduced
/loginbeing demanded repeatedly within a single active session (not just at wake-from-sleep) while other panes kept working, then resolved instantly on re-login — consistent with the "loser of the race clobbers the winner's rotated token" theory rather than a genuinely expired/invalid refresh token.One additional data point that might be useful: in the same window as one of these forced re-logins, the CLI's own auto-mode safety classifier (the gate that approves/denies tool calls) failed with
"claude-sonnet-5[1m] is temporarily unavailable, so auto mode cannot determine the safety of Bash right now"— a different-looking error, but timed with the auth failure. If the classifier's own backend call authenticates through the same wipedclaudeAiOauthcredential, a blanked token would plausibly surface there as "model unavailable" rather than an auth error, which could be misleading anyone trying to correlate incidents by error text alone.Set up a lightweight watcher (a background pane polling
claude -pevery 5 min, logging process ancestry + exit code on first failure) to try to catch a live wipe with the concurrency angle from worktree-pane tools like Supacode specifically — happy to share results if useful.Bounded macOS multi-profile diagnostic from Claude Code 2.1.247
Separate absolute CLAUDE_CONFIG_DIR paths with matching CLAUDE_SECURESTORAGE_CONFIG_DIR values produced separate Keychain namespaces
Verified mapping:
Both variables used the identical literal absolute path with no trailing slash
Observed across four concurrent processes in two namespaces:
Limits:
Can we please get this fixed as soon as possible this is making my t3 code unusable