macOS: concurrent claude processes zero MCP OAuth entries in shared Keychain blob despite valid refresh tokens

Status Open
Reported on v2.1.268
Maintainer reply None cached
Activity 4 comments · opened Sep 11, 2026

Summary

On macOS, MCP OAuth entries in the shared Keychain item Claude Code-credentials get rewritten with empty accessToken/refreshToken (and no clientId) while the refresh tokens are still valid, when several claude processes touch the store concurrently. The user then has to re-run /mcp and re-authenticate every server on every new session, even seconds after authenticating in another terminal. Related but distinct from #91009 (Windows, file store, claudeAiOauth login); this report is macOS Keychain, MCP-only, with a concrete trigger.

Environment

  • Claude Code 2.1.268 (native install), macOS 26 (Darwin 25.6.0)
  • Remote HTTP MCP servers: atlassian (user scope), notion (user scope), plugin:slack:slack
  • 7 concurrent claude processes at the time, across versions 2.1.206 (x3, month-old sessions), 2.1.263, 2.1.267, 2.1.268 (x2)

Observed

Timeline (UTC, 2026-09-11):

  • 04:29:47 Keychain item modified by a healthy refresh. Blob holds valid access + refresh tokens for all three servers (expiries 7h, 12h, 24h out).
  • 04:32:14 and 04:39:19 ~/.claude/mcp-needs-auth-cache.json written flagging all three servers; a tokenless mcpOAuth stub blob (same keys, empty tokens) written to the fallback ~/.claude/.credentials.json. Coincides with claude mcp list runs from a non-interactive shell, one of which was killed after ~75s.
  • 04:42:16 another claude mcp list run. Keychain item rewritten: notion|… and atlassian|… entries now have empty accessToken/refreshToken and no clientId; plugin:slack:slack|… entry intact. claude mcp list reports "Needs authentication" for the two.

No /logout, no "Clear authentication", CLAUDE_CONFIG_DIR unset everywhere, login keychain unlocked with no timeout. User history shows 33 /mcp re-auths over two months, clustered at session starts.

Expected

Entries with a valid refresh token are never zeroed by another process. A failed refresh in one process should not overwrite tokens another process just rotated; the store should be re-read before write or the write should be per-entry.

Repro shape

  1. Have 2+ remote OAuth MCP servers authenticated.
  2. Keep several long-lived claude sessions open (ideally across an auto-update so versions are mixed).
  3. Run claude mcp list from another shell two or three times concurrently; kill one mid-run.
  4. Watch security find-generic-password -s "Claude Code-credentials" mdat and the entries' token fields.

View original on GitHub ↗

3 Comments

erikedhagen · 26 days ago

Additional data from a macOS setup hitting this 1–3×/day. The repro note here says "ideally across an auto-update so versions are mixed" — in my logs the auto-update isn't just one way to create the race, it's the dominant trigger, which makes this reproducible on demand rather than opportunistically.

Update correlation

Six of ten incidents over eight days landed within minutes of a new version being installed, and every incident wiped every MCP server at once (3 servers, then 4 once another was added, then 5):

09-18T01:32:56  5 servers   desktop bundle 2.1.274 installed 01:32:58   (+2s)
09-17T06:54:48  4 servers   cli 2.1.274 installed 07:17                 (+22m)
09-15T23:06:26  4 servers   desktop bundle 2.1.271 installed 23:21      (+15m)
09-15T06:46:55  4 servers   cli 2.1.272 installed 07:05                 (+18m)
09-14T06:24:51  3 servers   first session on cli 2.1.268                (+0s)
09-11T07:14:25  3 servers   first session on desktop 2.1.266            (+4m)

Nine versions shipped in eight days here (2.1.260, 265, 266, 267, 268, 270, 271, 272, 274), which matches the ~1/day prompt rate. Across the full log history: 49 incidents affecting 34 distinct sessions.

The mechanism fits the race described in the issue — an update starts a new-version process while existing long-lived sessions keep running on the old one, so a mixed-version concurrent write window opens on every single update, without the user doing anything unusual.

Different failure shape than reported here

This issue describes entries rewritten as tokenless stubs (empty accessToken/refreshToken, no clientId). What I see is the entry absent entirely — both of these, together, at the moment of loss:

No token data found
No client info found; deferring client registration to authentication time

The OAuth client registration is gone too, so the next connection re-runs DCR. Possibly the stub is created first and then removed by the clear tokenless stub path, but worth distinguishing since the user-visible result is a full re-registration rather than a token refresh.

Tokens are live when this happens, which rules out expiry — three minutes before one wipe the same session logged:

07:11:37Z  Returning tokens, Expires in: 1355s
07:14:25Z  No token data found

Two code paths that may be worth checking

From the bundled binary (2.1.266/2.1.274):

  1. The keychain read shells out with a 2s timeout and, on failure with no prior cache, caches null and returns it:

``js
_ee(
security find-generic-password -a "${o}" -w -s "${n}", {timeout: 2000})
// on throw: if (r.data !== null) → serve stale cache
// else → e.cache = {data: null, cachedAt: Date.now()}; return null
``

A null here is indistinguishable downstream from "never authenticated" — it surfaces as No token data found and prompts re-auth rather than raising. There's a lastReadFailure backoff that then keeps serving the cached null without retrying the keychain.

  1. mutate bases the write on that possibly-null read:

``js
let o = n ?? {}, s = r(o); return s === o ? {success:!0} : await e.update(s, a)
``

So a timed-out read followed by any routine write (saving discovery state on reconnect is enough) persists a blob containing only the new entry. That would explain why this doesn't self-heal and requires a full re-auth.

Workaround that removes the trigger, for anyone else landing here: {"env": {"DISABLE_AUTOUPDATER": "1"}} in ~/.claude/settings.json, then update deliberately with all sessions closed. Note this doesn't appear to cover the desktop app's separately-managed copy under ~/Library/Application Support/Claude/claude-code/, which produced the 09-18 incident above.

geranazavr555 · 23 days ago

Another data point: same shape (a process killed mid-refresh), but here the killer is the background daemon retiring idle sessions

Claude Code 2.1.278 (native), macOS 26 (Darwin 25.6.0). 13 remote HTTP MCP servers behind one gateway; the authorization server is Zitadel (rotating single-use refresh tokens, access token TTL 600 s), with a pre-registered clientId in .mcp.json. Daemon-managed background sessions (fleet jobs and pre-warmed spares) run alongside interactive ones.

Symptom. Every day or two a subset of servers fails permanently with invalid_request: OIDCS-28ubl Errors.OIDCSession.RefreshTokenInvalid, and only /mcp → Authenticate recovers. Unlike the original report, the entry is not zeroed: it still holds a refresh token that the AS has already consumed.

What the logs show. For each server's first failing refresh, I worked out when the token then stored in Keychain was issued (from the Token expires in -Ns debug line: issued = log time − N − 599 s). For several servers, that time matches no Token refresh successful in any mcp-logs-* file. It does match, to the second, the daemon retiring a background session in ~/.claude/daemon.log (all times 2026-09-20 UTC):

| daemon.log | stored token issued at | servers |
|---|---|---|
| 12:26:05.752 bg retire …: settled, idle 61m | 12:26:05 | 2 |
| 12:48:05.792 bg retire …: settled, idle 83m | 12:48:05 | 2 |
| 13:17:05.850 bg retire …: settled, idle 86m | 13:17:06 | 7 |

bg retire → bg settled takes a median of 0.73 s over 198 retires in my daemon.log. A successful refresh cycle in the debug logs (per-server refresh lock → re-read Keychain → token POST → saveTokens) takes a median of 1.29 s (n = 73, range 0.21–2.60 s).

Mechanism, as far as I can reconstruct it.

  1. After 60+ minutes idle, every access token has expired.
  2. During teardown, something sends requests on the HTTP transports. _commonHeaders() calls authProvider.token(), which starts a proactive refresh (≤ 300 s left) on every connected server in parallel.
  3. The process is gone about 0.7 s later, so each refresh stops at a random point:
  • before the token POST: harmless;
  • after the AS has rotated the refresh token but before saveTokens has persisted the new pair: fatal. Keychain keeps the consumed refresh token, and from then on every process gets RefreshTokenInvalid;
  • after the save: fine, just a token nobody logged.
  1. The retiring process's debug log is never flushed, so none of this shows up in mcp-logs-*.

This accounts for all 13 servers. The 7 servers saved at 13:17:06 survived the next three retires (13:18:05, 13:20:05, 13:21:05) only because they still had more than 300 s left, so no refresh started. The 6 that died all had an expired token at a later retire, and a new pair was never persisted after that.

Ruled out.

  • Refresh-token idle expiry: the surviving servers refreshed fine after 12.5 h idle.
  • Lost update in the shared blob: in 2.1.278, every write goes through mutate() under the cross-process .storage-write lock with a fresh re-read. refreshAuthorization also takes a per-server mcp-refresh-*.lock and re-reads before refreshing. The "re-read before write" part of this issue looks implemented already; here the token is lost before any write happens.
  • AS-side reuse detection: Zitadel only rejects a reused refresh token and doesn't revoke the family (zitadel/zitadel#7321). So a race loser can't kill the winner; only "rotated but never persisted" is fatal.

I couldn't pin down which teardown path sends the requests; terminateSession() doesn't appear to be called.

Suggestions.

  • Don't start OAuth refreshes during shutdown, or send teardown requests without triggering one.
  • If a refresh is in flight, let it persist before exiting. For example, the daemon could wait while an mcp-refresh-*.lock is held instead of killing the session after about 0.7 s.
  • Flush MCP debug logs on shutdown. Right now the fatal refreshes are invisible.

The original report here (a claude mcp list killed mid-run just before the loss) looks like the same shape with a different killer.

matsfinsas · 20 days ago

Adding a second, update-independent trigger with the same failure shape @erikedhagen describes: entries absent entirely, No token data found + No client info found, every server at once, tokens still valid. Here it is deterministic: every 8-hourly claudeAiOauth renewal by a Claude Desktop–spawned session erases the whole mcpOAuth section. On three consecutive days, every renewal that had MCP tokens to lose, lost them.

Environment

  • macOS (Darwin 27.0.0, Apple Silicon), default config dir, a single Claude Code-credentials item
  • Claude Desktop (Code tab), bundled engine 2.1.280 (entrypoint=claude-desktop, agent-sdk 0.3.280), ~14 desktop-spawned sessions alive. Their env includes CLAUDE_CODE_OAUTH_TOKEN and CLAUDE_CODE_SDK_HAS_HOST_AUTH_REFRESH
  • Claude.ai login via CLI 2.1.281 /login. No CLI process was alive at the time of the wipe
  • Remote HTTP OAuth MCP servers: intercom-eu, plugin:posthog:posthog, plugin:slack:slack, stripe
  • No install in the window: last install was CLI 2.1.281, 14h before the wipe

Timeline, 2026-09-24 UTC (per-session mcp-logs-* plus unified log, process == "security")

| Time | Event |
|---|---|
| 07:20:26 | Intercom authenticated via /mcp (1h access token + refresh token) |
| 08:17:33, 09:32:36 | Intercom refreshes succeed and are saved. MCP saves through mutate() keep the full blob |
| 09:38:59.65 | Last Keychain read proven to still contain the PostHog/Slack tokens (security read, Returning tokens 20 ms later) |
| 09:40:50.613 | claudeAiOauth renewed (new expiresAt = 17:40:50.613, exactly +8h) |
| 09:40:50.660 | The only write to the item between 09:32 and 09:47: SecKeychainItemModifyContent, right after two security reads from the same parent (consecutive PIDs). No MCP log activity anywhere at that moment |
| 09:47:21.887 | The next session to start logs No token data found for all four servers at once (plus No client info found), before writing anything itself |

Afterwards the item held only claudeAiOauth. The unified log shows the renewal writes on an 8h chain from the last /login (2026-09-23 17:44Z, 2026-09-24 01:41Z, 09:40Z). Two earlier losses on 2026-09-23 (detected 07:31Z and 09:48Z) fall on the same 8h renewal boundaries.

Ruled out

  • Another writer: the unified log shows no other process modifying the item.
  • Updates: no install near any of the three losses.
  • Stale in-memory blob written back: in 2.1.280/2.1.281 every write to this item goes through mutate(), which re-reads via security find-generic-password under the .storage-write lock.

Likely mechanism (unverified). This matches the second code path in the comment above. The claudeAiOauth save from a desktop-spawned engine goes through mutate() with host-managed credentials. If the fresh read inside it returns null, the base becomes {} and the write keeps only the login. (2.1.280 appears to map security exit 36 "interaction not allowed" and empty output to null.) MCP token saves in the same sessions never show this. Related: #94464 (same desktop-engine renewal path, different damage) and #90647 (mcpOAuth lives inside the account credential, so any rewrite of the login can take the MCP grants with it).

Suggested fix: when the read before a write fails or comes back empty, abort or retry the save instead of writing a blob built from {}. Saving claudeAiOauth should never drop mcpOAuth, or MCP grants could live in their own Keychain item.

Happy to capture the parent process of the writing security call at the next renewal if that helps pin down the exact code path.

Showing cached comments. Read the full discussion on GitHub ↗