[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

Status Open
Reported on v2.1.198
Maintainer reply None cached
Activity 1 comment · opened Jul 16, 2026

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.json keeps the superseded token (mtime doesn't change).
  • /login persists the new grant to .credentials.json only. 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:55 daemon log: auth: proactive refresh starting:56 proactive refresh succeeded
  • 12:36:56 Keychain item Claude Code-credentials created — cdat matches the log to the second
  • .credentials.json mtime unchanged (still the 04:40 login)
  • ~12:40 an 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, pgrep clean overnight — verified).
  • Keychain item nevertheless recreated at 16:16:47 by a plain long-lived terminal session; the file kept its 05:42 token → 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 /login succeeded → only .credentials.json updated. Stale Keychain item manually deleted at 04:58 (so no stale store existed).
  • 05:30:49 a 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 /login at 05: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.
  • /login should 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

  1. macOS, subscription OAuth login, default ~/.claude config dir.
  2. Open one interactive session and leave it running past the ~8h proactive-refresh point.
  3. Observe: Keychain item Claude Code-credentials mdat advances; ~/.claude/.credentials.json mtime does not (split-brain established).
  4. Run /login from 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.

View original on GitHub ↗

This issue has 1 comment on GitHub. Read the full discussion on GitHub ↗