Zeroed Keychain credential shadows valid ~/.claude/.credentials.json, forcing repeated /login (macOS)

Status Open
Reported on v2.1.261
Maintainer reply None cached
Activity 0 comments · opened Sep 9, 2026

Summary

On macOS, the Keychain credential (Claude Code-credentials) and
~/.claude/.credentials.json can end up both present with divergent refresh
tokens
. Because the Keychain is consulted first and a zeroed-but-present
entry is not null, it shadows a perfectly valid file credential. Every
invocation then fails with:

Failed to authenticate: OAuth session expired and could not be refreshed

Once the shadowed file credential also expires, the failure becomes
Not logged in · Please run /login and a manual re-login is the only recovery.

This has recurred roughly daily for two weeks on a Max subscription. It is
especially disruptive for non-interactive invocations (claude -p spawned by a
task runner), where each occurrence fails every queued job until a human
notices and logs in.

Environment

  • macOS 26.6.2 (Darwin 25.6.0), Apple Silicon
  • claude-code 2.1.261, later 2.1.263 (npm global install)
  • Subscription: Max. Single machine, single account.
  • Login keychain: no-timeout, no lock-on-sleep flag set

Observed storage behaviour

From the shipped bundle, credential storage is Keychain-primary with a
plaintext-file fallback:

  • Read: the Keychain is read first; the file is consulted only when the

Keychain read returns null.

  • Write: a successful Keychain write deletes the file; if the Keychain write

fails non-transiently, the credential is written to the file and the Keychain
entry is deleted.

So the intended invariant is exactly one store. Both existing at once means
the invariant has broken — and the read path has no way to recover, because a
Keychain entry holding accessToken: "" is present, not null.

Captured timeline

Instrumented with a 1 Hz poller recording token lengths and expiries (never
values):

09-07 23:20:39  refresh OK  -> Keychain, access token expires 09-08 07:20:38
~09-08 07:20    access token expires (machine asleep since 00:46)
09-08 07:26:14  refresh SUCCEEDS but is persisted to the FILE, rotating the
                refresh token. The stale Keychain entry survives.
09-08 08:26:32  machine wakes
09-08 08:34:44  invocation -> reads KEYCHAIN first -> uses the OLD, already
                rotated refresh token -> server rejects -> credential WIPED

Resulting Keychain state (values never printed):

claudeAiOauth.accessToken  len = 0
claudeAiOauth.refreshToken len = 0
expiresAt                  = 0
refreshTokenExpiresAt      = 2026-10-06   <- still a month away

The refresh token had not expired. It had been superseded. The wipe is the
consequence of the failed refresh, and it destroys the credential rather than
leaving the previous one intact.

Meanwhile ~/.claude/.credentials.json held a valid 108-char access token,
unreachable because the zeroed Keychain entry shadowed it.

Impact

  • Repeated forced /login, roughly daily.
  • Non-interactive/headless runs fail in bulk with no self-recovery.
  • The same wipe empties the mcpOAuth section, so MCP servers that were

authorised also lose their tokens.

Suggested fixes (any one would resolve it)

  1. Treat an unusable Keychain credential as null on read — if

accessToken is empty, fall through to the file instead of shadowing it.
This alone makes the failure self-healing.

  1. Do not persist a cleared credential when a refresh fails. Leaving the

previous credential in place allows a later retry; zeroing it guarantees a
manual login.

  1. Reconcile rather than shadow when both stores exist — prefer the one with

the later expiresAt, then delete the loser, restoring the single-store
invariant.

Safe diagnostics

Prints only lengths and expiries, never token values:

security find-generic-password -s "Claude Code-credentials" -w \
| python3 -c 'import sys,json,datetime; o=json.load(sys.stdin)["claudeAiOauth"]; \
print("access",len(o["accessToken"]),"refresh",len(o["refreshToken"]), \
"exp",o["expiresAt"] and datetime.datetime.fromtimestamp(o["expiresAt"]/1000))'

Both stores existing simultaneously is itself the diagnostic signal.

Workaround

Deleting the zeroed Keychain entry lets reads fall through to the valid file:

security delete-generic-password -a "$USER" -s "Claude Code-credentials"

This only works while the shadowed file credential is still valid; once it has
expired too, a manual /login is unavoidable.

View original on GitHub ↗