macOS: concurrent claude processes zero MCP OAuth entries in shared Keychain blob despite valid refresh tokens
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
claudeprocesses 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.jsonwritten flagging all three servers; a tokenlessmcpOAuthstub blob (same keys, empty tokens) written to the fallback~/.claude/.credentials.json. Coincides withclaude mcp listruns from a non-interactive shell, one of which was killed after ~75s. - 04:42:16 another
claude mcp listrun. Keychain item rewritten:notion|…andatlassian|…entries now have emptyaccessToken/refreshTokenand noclientId;plugin:slack:slack|…entry intact.claude mcp listreports "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
- Have 2+ remote OAuth MCP servers authenticated.
- Keep several long-lived
claudesessions open (ideally across an auto-update so versions are mixed). - Run
claude mcp listfrom another shell two or three times concurrently; kill one mid-run. - Watch
security find-generic-password -s "Claude Code-credentials"mdatand the entries' token fields.
Showing cached comments. Read the full discussion on GitHub ↗
3 Comments
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):
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, noclientId). What I see is the entry absent entirely — both of these, together, at the moment of loss: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 stubpath, 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:
Two code paths that may be worth checking
From the bundled binary (2.1.266/2.1.274):
nulland returns it:``
jssecurity find-generic-password -a "${o}" -w -s "${n}"_ee(
, {timeout: 2000})
``// on throw: if (r.data !== null) → serve stale cache
// else → e.cache = {data: null, cachedAt: Date.now()}; return null
A
nullhere is indistinguishable downstream from "never authenticated" — it surfaces asNo token data foundand prompts re-auth rather than raising. There's alastReadFailurebackoff that then keeps serving the cachednullwithout retrying the keychain.mutatebases the write on that possibly-nullread:``
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.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
clientIdin.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 -Nsdebug line: issued = log time − N − 599 s). For several servers, that time matches noToken refresh successfulin anymcp-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 settledtakes a median of 0.73 s over 198 retires in mydaemon.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.
_commonHeaders()callsauthProvider.token(), which starts a proactive refresh (≤ 300 s left) on every connected server in parallel.saveTokenshas persisted the new pair: fatal. Keychain keeps the consumed refresh token, and from then on every process getsRefreshTokenInvalid;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.
mutate()under the cross-process.storage-writelock with a fresh re-read.refreshAuthorizationalso takes a per-servermcp-refresh-*.lockand 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.I couldn't pin down which teardown path sends the requests;
terminateSession()doesn't appear to be called.Suggestions.
mcp-refresh-*.lockis held instead of killing the session after about 0.7 s.The original report here (a
claude mcp listkilled mid-run just before the loss) looks like the same shape with a different killer.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-hourlyclaudeAiOauthrenewal by a Claude Desktop–spawned session erases the wholemcpOAuthsection. On three consecutive days, every renewal that had MCP tokens to lose, lost them.Environment
Claude Code-credentialsitementrypoint=claude-desktop, agent-sdk 0.3.280), ~14 desktop-spawned sessions alive. Their env includesCLAUDE_CODE_OAUTH_TOKENandCLAUDE_CODE_SDK_HAS_HOST_AUTH_REFRESH/login. No CLI process was alive at the time of the wipeintercom-eu,plugin:posthog:posthog,plugin:slack:slack,stripeTimeline, 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 (
securityread,Returning tokens20 ms later) || 09:40:50.613 |
claudeAiOauthrenewed (newexpiresAt= 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 twosecurityreads 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 foundfor all four servers at once (plusNo 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
mutate(), which re-reads viasecurity find-generic-passwordunder the.storage-writelock.Likely mechanism (unverified). This matches the second code path in the comment above. The
claudeAiOauthsave from a desktop-spawned engine goes throughmutate()with host-managed credentials. If the fresh read inside it returnsnull, the base becomes{}and the write keeps only the login. (2.1.280 appears to mapsecurityexit 36 "interaction not allowed" and empty output tonull.) MCP token saves in the same sessions never show this. Related: #94464 (same desktop-engine renewal path, different damage) and #90647 (mcpOAuthlives 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
{}. SavingclaudeAiOauthshould never dropmcpOAuth, or MCP grants could live in their own Keychain item.Happy to capture the parent process of the writing
securitycall at the next renewal if that helps pin down the exact code path.