[BUG] macOS: token rotations persist to Keychain only while /login writes .credentials.json only — store split-brain revokes the token family, forcing repeated re-login
Preflight Checklist
- [x] I have searched existing issues and this hasn't been reported yet
- [x] This is a single bug report (please file separate reports for different bugs)
- [x] I am using the latest version of Claude Code
What's Wrong?
On macOS with a subscription OAuth login, rotated refresh tokens are persisted inconsistently between the two credential stores — the login Keychain item (Claude Code-credentials) and ~/.claude/.credentials.json:
- A long-lived process (the background daemon, or an ordinary interactive session) performs the ~8h proactive refresh and persists the rotated token to the Keychain only.
.credentials.jsonkeeps the superseded token (mtime doesn't change). /loginpersists the new grant to.credentials.jsononly. It neither updates nor removes an existing Keychain item.- On startup, processes prefer the Keychain over the file (daemon log:
auth: token found via keychain re-check).
Since refresh tokens are single-use, any process still holding the superseded copy eventually presents it → server-side reuse detection revokes the whole token family → every session gets Login expired · Please run /login. With at least one long-lived session, this recurs roughly every token-expiry cycle, and each recovery /login is itself revoked within minutes unless the user manually deletes the stale Keychain item AND terminates every process started before the login.
This is adjacent to #76905 (concurrent-refresh race on the Keychain item) and #76561 (file-store clobbering), but it is a distinct defect: even perfectly serialized refreshes would still split-brain, because rotation writes and /login writes target different stores. Timeline C below reproduces the revocation with no concurrent refresh involved.
Evidence — three independent reproductions (one always-on Mac mini, times UTC)
A. Daemon rotates → Keychain-only (v2.1.207, 2026-07-13)
12:36:55daemon log:auth: proactive refresh starting→:56 proactive refresh succeeded12:36:56Keychain itemClaude Code-credentialscreated —cdatmatches the log to the second.credentials.jsonmtime unchanged (still the 04:40 login)~12:40an interactive session holding the file token hit its own expiry and presented the superseded refresh token → family revoked → all sessions logged out within 2h.
B. Ordinary interactive session rotates → Keychain-only, no daemon at all (2026-07-14→15)
- The daemon was deliberately removed for isolation (process killed
01:47, no LaunchAgent,pgrepclean overnight — verified). - Keychain item nevertheless recreated at
16:16:47by a plain long-lived terminal session; the file kept its05:42token → stale-token reuse overnight → "Not logged in" next morning. So the Keychain-only writer is not just the daemon.
C. /login writes file-only; a pre-login session kills the new family in 33 minutes (v2.1.210, 2026-07-16)
04:57/loginsucceeded → only.credentials.jsonupdated. Stale Keychain item manually deleted at04:58(so no stale store existed).05:30:49a session started before that login rotated its old-family token and wrote a fresh Keychain item (cdat=mdat=20260716053049Z) → the 04:57 family was revoked → forced another/loginat05:46.- No concurrency race needed: one rotation + two stores + one stale in-memory token is sufficient.
What Should Happen?
- A token rotation should persist atomically to every store the app reads (or to one canonical store), so no reader can pick up a superseded refresh token.
/loginshould update or remove the Keychain item, not only the file.- Already-running sessions should re-read the canonical store (or be invalidated/notified) after a re-login, instead of later presenting revoked-family tokens.
Error Messages/Logs
Error during compaction: Login expired · Please run /login
● Please run /login · API Error: 401 OAuth access token has been revoked
[supervisor] auth: proactive refresh starting
[supervisor] auth: proactive refresh succeeded <- Keychain cdat matches this second; .credentials.json untouched
[supervisor] auth: token found via keychain re-check <- Keychain is preferred on read
Steps to Reproduce
- macOS, subscription OAuth login, default
~/.claudeconfig dir. - Open one interactive session and leave it running past the ~8h proactive-refresh point.
- Observe: Keychain item
Claude Code-credentialsmdatadvances;~/.claude/.credentials.jsonmtime does not (split-brain established). - Run
/loginfrom any session (or start a process that reads the file store): a superseded refresh token gets presented within one rotation cycle → 401, all sessions logged out.
Diagnostic one-liner while it's happening: compare security find-generic-password -s "Claude Code-credentials" | grep mdat (UTC) against stat -f %Sm ~/.claude/.credentials.json — divergence = split-brain in progress.
Workaround (for other affected users)
After every "login expired": /login, then security delete-generic-password -s "Claude Code-credentials", then terminate every claude process started before that login. Stable only when all three are done (skipping the third step re-revoked the fresh grant in 33 minutes, timeline C).
Side note: with a custom CLAUDE_CONFIG_DIR the Keychain service name gains a -<sha256(configDirPath)[:8]> suffix, isolating the stores per config dir — this machine was symptom-free for 8 days while using a custom dir with a single fresh login, and regressed immediately on returning to the default dir whose plain-named Keychain item predated the login.
Claude Model
claude-fable-5 (also seen on opus)
Is this a regression?
Unknown — reproduced on 2.1.198, 2.1.207, and 2.1.210.
Claude Code Version
2.1.210 (Claude Code)
Platform
Claude subscription (Max)
Operating System
macOS (Darwin 25.5.0, Mac mini)
Terminal/Shell
zsh (Terminal.app + tmux + SSH sessions)
Additional Information
The machine intentionally ran with zero background daemons during reproduction B (LaunchAgent removed, pgrep -f "daemon run" clean), ruling out the daemon as a necessary component. Reproduction C rules out concurrent-refresh racing as a necessary component. The minimal failure needs only: one long-lived process, one rotation persisted to a store the next consumer doesn't read.
This issue has 1 comment on GitHub. Read the full discussion on GitHub ↗