anthropics / anthropics/claude-code

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

Aperta
#93,051 0 commenti 0 reazioni 0 assegnatari Vedi su GitHub
area:auth bug has repro platform:macos
Lingua principale
Python
Stelle
145k
Fork
23.1k
Metriche di merge delle PR
Metriche PR in attesa

Descrizione

## 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.

Guida per i contributori

Nessuna guida per i contributori indicizzata per questo repository

Direzione di ricerca

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.

Scritto dal modello di indicizzazione a partire dal testo della issue.

Valutazione

Stack tecnologico
macos
Ambito
authentication, security
Tipo di issue
Bug
Difficoltà
4/5
Tempo stimato
3-5 giorni
Stato di attività
Attiva
Chiarezza
Abbastanza chiara
Idoneità per principianti
45/100

Ricevi le nuove issue nella tua casella

Un breve riepilogo di issue GitHub adatte ai principianti.