TUI /login reports success but never persists credentials — Keychain timeout classified as transient skips the file fallback
Preflight Checklist
- [x] I have searched existing issues and this hasn't been reported yet
- [x] This is a single bug report
- [x] I am using the latest version of Claude Code
What's Wrong?
Running /login inside the TUI reports Login successful, but credentials are never persisted. Every subsequent invocation (claude, claude rc) fails with Not logged in · Please run /login, so the user gets stuck in an endless login loop.
Running claude auth login from the shell instead works correctly and fixes the state permanently.
Related prior reports describe the same symptom without identifying a mechanism: #44585 (closed as NOT_PLANNED, Windows), and several macOS users in its comment thread. This report adds a concrete root cause for the macOS Keychain path.
Root Cause Analysis
Credential storage uses a "keychain primary + .credentials.json fallback" composer. Its update() skips the fallback whenever the primary write is flagged transient:
async update(a, n){
let s = await e.update(a); // e = keychain provider
if (s.success) { /* ... */ return s }
if (s.transient) // <-- bug
return T("secure_storage_credentials_write", "primary_transient_skip_fallback"), s;
let g = await t.update(a, n); // t = .credentials.json — never reached
/* ... */
}
transient is set exclusively by a timeout in the keychain provider:
if (g.exitCode !== 0) return { success: false, transient: g.timedOut };
var w = 2000; // keychain operation timeout: 2s
When macOS shows a Keychain authorization dialog, security add-generic-password blocks until the user clicks. That reliably exceeds the 2s timeout, so:
- OAuth succeeds, token obtained
- Keychain write times out ->
timedOut = true - Provider returns
{ success: false, transient: true } - Composer takes
primary_transient_skip_fallbackand skips the file fallback - Credentials end up in neither the Keychain nor
.credentials.json - UI still prints
Login successful, because the OAuth layer did succeed
Corroborating evidence on the affected machine: ~/.claude/.credentials.json did not exist. Had the fallback run, it would have been written even though the Keychain write failed.
Two distinct problems
1. Wrong transient classification. "Waiting for the user to approve a Keychain dialog" is not a transient fault that resolves on retry — it is a deterministic, human-gated failure. It is exactly the case where falling back to file storage is correct, yet it is the one case that disables the fallback.
2. Persistence failure is silent. update() returns { success: false } and the UI still reports Login successful. Another write path in the same binary handles this properly:
// timeout: 5000, and throws on failure
if (u.exitCode !== 0) { throw Error(`Failed to save API key to macOS Keychain...`) }
The two paths disagree on both the timeout (2000 vs 5000) and on error handling (silent vs throw). This is consistent with claude auth login working while TUI /login does not.
What Should Happen?
- A Keychain write that fails while awaiting user authorization should not be classified
transient - That case should fall through to the
.credentials.jsonfallback - If credentials could not be persisted by any backend, the UI must not print
Login successful
Steps to Reproduce
- On macOS, be in a state where the Keychain prompts for authorization on write (e.g. after the Claude Code binary path changes across an update, which invalidates the existing ACL entry)
- Launch
claudeand run/login - Complete OAuth; observe
Login successful - Exit, then run
claudeorclaude rc->Not logged in · Please run /login - Repeat steps 2-4 indefinitely — it never persists
- Run
claude auth loginfrom the shell instead -> works, and the state persists
Workaround
Use claude auth login instead of the in-TUI /login.
Claude Code Version
2.1.246
Platform
Claude Max subscription (claude.ai OAuth)
Operating System
macOS (Darwin 25.5.0, Apple Silicon)
Is this a regression?
I don't know
Additional Information
Diagnosis was done by inspecting strings/code in the shipped binary at ~/.local/share/claude/versions/2.1.246. Minified identifiers are quoted as they appear there.
Note that #28541 (security -i 4096-byte buffer) is a different branch of the same provider and appears already fixed — the shipped code carries an Ie = 4032 length guard. The bug reported here is in the transient short-circuit, not in the payload-size path.