anthropics / anthropics/claude-code
Zeroed Keychain credential shadows valid ~/.claude/.credentials.json, forcing repeated /login (macOS)
- Dominant language
- Python
- Stars
- 145k
- Forks
- 23.1k
- PR merge metrics
- PR metrics pending
Description
## 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.
2. **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.
3. **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:
```bash
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:
```bash
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.
Contributor guide
No contributing guide indexed for this repository
Research direction
Start by locating the credential-storage read and write paths in the shipped bundle, focusing on the Keychain item named Claude Code-credentials and ~/.claude/.credentials.json. Reproduce the case where both stores exist and the Keychain values are empty, then verify that a valid file credential is selected and that failed refreshes do not destroy recoverable credentials.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- macos
- Domain
- authentication, security
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Active
- Clarity
- Mostly clear
- Newbie friendliness
- 45/100