[macOS] Claude Desktop's embedded Claude Code rotates the CLI's OAuth refresh token but can only persist to `.credentials.json` — Keychain stays stale, terminal CLI gets `Login expired` daily
Related (does not duplicate): #78020 (store split-brain from /login vs daemon refresh), #80085 (concurrent instances racing on one Keychain item). Neither covers the Claude Desktop → embedded engine path below, which is a deterministic split-brain, not a race.
Environment
- macOS (Apple Silicon), login keychain unlocked,
security show-keychain-info→no-timeout - Claude Code CLI 2.1.270 (npm install), authenticated via
/login(Claude Max subscription, no API key) - Claude Desktop with its embedded Claude Code engine 2.1.270 (
~/Library/Application Support/Claude/claude-code/2.1.270/claude, spawned through thedisclaimerhelper,entrypoint=claude-desktop, no TTY, env hasCLAUDE_CODE_OAUTH_TOKEN+CLAUDE_CODE_SDK_HAS_HOST_AUTH_REFRESH+CLAUDE_CODE_SDK_HAS_OAUTH_REFRESH) - Same user, same
~/.claudeconfig dir for both. Windows machine with the same workflow (file store only) never shows the symptom.
Symptom
Terminal CLI prints Login expired · Please run /login roughly once per access-token lifetime (≈ daily; on 2026-09-14 22:47 and 2026-09-15 15:12 local), even though nothing in the terminal ever logged out. Headless claude -p runs launched from other tools fail with authentication_error in the same windows.
Evidence (one full cycle, local time, all from disk)
| time | fact | source |
|---|---|---|
| 09-14 22:48 | /login in the terminal → Keychain item Claude Code-credentials updated (new access/refresh pair) | Keychain mdat |
| 22:50 → 13:25 | A Claude Desktop engine session stays alive all night (process started the day before) | ps, session transcript |
| ~06:48 | Keychain access token reaches its 8 h expiry | expiresAt |
| 08:35:36 | That Desktop session ends a turn (stop_hook_summary) | transcript |
| 08:36:05 | ~/.claude/.credentials.json is created (birth time). Its mcpOAuth section is byte-identical to the Keychain's for 26 of 27 entries (so it was populated from a Keychain read), and its claudeAiOauth is a new pair (only accessToken/refreshToken/expiresAt/scopes, i.e. the shape of a refresh response). subscriptionType/rateLimitTier are absent. | stat -f %SB, per-entry sha256 comparison |
| 08:30–15:03 | No other Claude Code process on the machine except Desktop-spawned sessions (entrypoint=claude-desktop) | scan of all ~/.claude/projects/*/*.jsonl timestamps |
| 15:03 | A headless claude -p (no env token, reads Keychain) → authentication_error in 9 s → the Keychain refresh token is already dead | its transcript |
| 15:12 | Terminal CLI → Login expired; /login at 15:15 writes a fresh pair to Keychain. The file keeps its diverged pair. | transcript, Keychain mdat |
| 15:15 → | Keychain claudeAiOauth fp b9cfbce7/ddf6f730, file fp 8f2b5874/70e3e546 (sha256[:8] of access/refresh). Two live token families, one account. | comparison script |
claude auth status from a non-TTY shell still reports the Keychain record (subscriptionType: max), confirming readers are Keychain-first.
What the 2.1.270 binary does (read from the bundled JS)
The credential store is a keychain-with-file-fallback composite:
- read: Keychain first; falls back to the file only when
security find-generic-passwordreturns 44 (not found), 36 (locked / interaction not allowed), or times out. - update(o):
i = keychain.read(); c = keychain.update(o); - if
c.success:if (i === null) file.delete()(migration) — this is the only code path that deletes.credentials.json; - else if not transient:
file.update(o); if that succeeds:if (i !== null) keychain.delete()— i.e. it tries to remove the now-stale Keychain item to avoid split-brain.
Failure mechanism
Inside the Desktop-spawned engine, Keychain read succeeds (the file got the Keychain's mcpOAuth), but add-generic-password fails and the subsequent delete-generic-password also fails (the Keychain item survives: cdat unchanged since 2026-08-18). Most likely the disclaimer sandbox permits security reads but not writes/deletes. Result:
- The engine consumed the shared refresh token (rotation → old refresh token revoked), persisted the successor only to
.credentials.json. - The Keychain item — still primary for every other process — now holds a revoked refresh token.
- The next process that needs a refresh (terminal CLI, headless
claude -p) fails →Login expired/authentication_error. /loginwrites Keychain, the file keeps the other family; the cycle repeats at the next expiry.
Why the engine used the shared store's token at all: its CLAUDE_CODE_OAUTH_TOKEN env value is identical across engines spawned 29 h apart (same fp), i.e. it is a stale access token, so the engine falls back to the store for a live one.
Expected
Either (a) the Desktop engine should not touch the shared store's refresh token when the host provides auth, or (b) when the fallback write is used, the failed keychain.delete() should be surfaced/retried, or (c) the reader should prefer whichever store holds the newer expiresAt. Any of these prevents the split-brain. Windows is immune because it has a single store.
Workaround that works (verified)
Give the terminal CLI its own long-lived subscription token: claude setup-token → export CLAUDE_CODE_OAUTH_TOKEN=… in a 600 file sourced from ~/.zshrc. claude auth status then reports authMethod: oauth_token, and the terminal no longer depends on the Keychain refresh chain the Desktop engine keeps rotating. (Closing Desktop sessions before using the CLI also avoids it, but is impractical.)
Diagnostics anyone can run
# both stores present?
ls -la ~/.claude/.credentials.json; security find-generic-password -s "Claude Code-credentials" | grep -E 'cdat|mdat'
# do they hold the same claudeAiOauth family? (prints sha256 prefixes only)
security find-generic-password -s "Claude Code-credentials" -w | python3 -c 'import json,sys,hashlib;o=json.load(sys.stdin)["claudeAiOauth"];print("keychain",hashlib.sha256(o["refreshToken"].encode()).hexdigest()[:8])'
python3 -c 'import json,hashlib,os;o=json.load(open(os.path.expanduser("~/.claude/.credentials.json")))["claudeAiOauth"];print("file",hashlib.sha256(o["refreshToken"].encode()).hexdigest()[:8])'
Different prefixes = split-brain.
---
_Diagnosed with Claude Code; evidence gathered from local transcripts, Keychain metadata and the 2.1.270 bundle. Happy to provide more detail._
This issue has 1 comment on GitHub. Read the full discussion on GitHub ↗