TUI /login reports success but never persists credentials — Keychain timeout classified as transient skips the file fallback

Status Open
Reported on v2.1.246
Maintainer reply None cached
Activity 0 comments · opened Aug 26, 2026

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:

  1. OAuth succeeds, token obtained
  2. Keychain write times out -> timedOut = true
  3. Provider returns { success: false, transient: true }
  4. Composer takes primary_transient_skip_fallback and skips the file fallback
  5. Credentials end up in neither the Keychain nor .credentials.json
  6. 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.json fallback
  • If credentials could not be persisted by any backend, the UI must not print Login successful

Steps to Reproduce

  1. 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)
  2. Launch claude and run /login
  3. Complete OAuth; observe Login successful
  4. Exit, then run claude or claude rc -> Not logged in · Please run /login
  5. Repeat steps 2-4 indefinitely — it never persists
  6. Run claude auth login from 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.

View original on GitHub ↗