anthropics / anthropics/claude-code

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

Open
#93,051 0 comments 0 reactions 0 assignees View on GitHub
area:auth bug has repro platform: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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.