[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

Status Open
Reported on v2.1.229
Maintainer reply None cached
Activity 5 comments · opened Aug 21, 2026

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:

  1. No cross-process coordination for claudeAiOauth refresh. 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.
  2. Clobber-on-failure. The loser of the race gets invalid_grant and 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_grant should 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.
  • claudeAiOauth refresh should go through the same cross-process lock that MCP credential writes use since v2.1.136.

Steps to reproduce (probabilistic)

  1. macOS, claude.ai subscription login, several long-lived Desktop Code sessions in parallel.
  2. Let the access token expire while the machine sleeps.
  3. Wake; sessions resume concurrently.
  4. Within days, the Keychain blob is rewritten with blanked tokens / expiresAt: 0 and 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.

View original on GitHub ↗

4 Comments

foxFallingSkies · 6 days ago

I had the same problem, but I was able to last week, starting last Friday.

VictorS-Tofu · 6 days ago

Seeing the same signature under a different concurrency source: Supacode spawns many parallel claude CLI sessions (one per git worktree pane, all under one victorstorti account), each going through login -flpzsh, all sharing the same Keychain item / ~/.claude credential store — architecturally the same shape as the 5 parallel Desktop-bundled engines in the OP, just via terminal panes instead of Desktop windows.

Reproduced /login being 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 wiped claudeAiOauth credential, 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 -p every 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.

vitaliyhayda · 2 days ago

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:

  1. Profile path: /Users/vhayda/.claude-p2
  2. Literal-path SHA-256: b3ca408abef222071c25189c7ffb7caac54dd2dff2ec940a35ef4f53c2a1537c
  3. First 8 hex characters: b3ca408a
  4. Keychain service: Claude Code-credentials-b3ca408a
  5. Default profile retained the unsuffixed Claude Code-credentials service

Both variables used the identical literal absolute path with no trailing slash

Observed across four concurrent processes in two namespaces:

  1. No 401
  2. No login loop
  3. No account crossover
  4. No wrong-account billing
  5. Cross-profile same-UUID resume preserved context
  6. Continuation billed the launching profile

Limits:

  1. This is a mitigation and diagnostic, not a root-cause claim
  2. The overnight renewal test was invalidated by an external harness terminating the processes
  3. Refresh-boundary survival remains unproven
  4. This does not establish whether the original wipe originates in Claude Code, T3, or their interaction
HannoKruger · 1 day ago

Can we please get this fixed as soon as possible this is making my t3 code unusable

Showing cached comments. Read the full discussion on GitHub ↗