Skip to content

All usage widgets show [No credentials] on macOS when the login keychain has multiple Claude Code-credentials items (different accounts) #521

Closed

Description

@vishnujayvel

Summary

On macOS, every usage widget (Session Usage, Weekly Usage, etc.) renders [No credentials] on some machines even though a valid Claude Code OAuth token is present in the keychain. The token is simply stored under a keychain item that ccstatusline's lookup doesn't select.

Root cause: the login keychain can contain more than one generic-password item under the service name Claude Code-credentials, differing only by account. ccstatusline reads with the service name only:

security find-generic-password -s "Claude Code-credentials" -w

With multiple items sharing that service, security returns whichever it matches first — which may be an item that has no claudeAiOauth (so parseUsageAccessToken returns null) — and ccstatusline never tries the other account.

How this happens

Claude Code itself always reads/writes the credential with an explicit account (the OS username):

security ... generic-password -a <os-username> -s "Claude Code-credentials" ...

On an affected machine, the keychain ends up with (at least) two items:

service account contents
Claude Code-credentials <os-username> (e.g. vishnu) { mcpOAuth, claudeAiOauth } ← the real token
Claude Code-credentials unknown { mcpOAuth } only ← no usage token

The unknown-account item appears to be written by an MCP-OAuth code path that didn't resolve a username. security -s with no -a can return that one first → [No credentials], deterministically wrong on that machine.

(There may also be many Claude Code-credentials-<hex> suffixed services — these are per-server MCP OAuth tokens and do not contain claudeAiOauth; the existing dump-keychain fallback that scans suffixed services can't help here because it excludes the plain service name and none of the suffixed items hold the account token.)

Reproduce

Structure-only probe (does not print token values):

# Enumerate accounts under the plain service:
security dump-keychain 2>/dev/null \
  | grep -B2 '"svce"<blob>="Claude Code-credentials"$' \
  | grep '"acct"' | sort -u
# On an affected machine you'll see two accounts, e.g. "unknown" and "<your-username>".

# Which item has the token? (structure only — prints true/false, never the token)
has_token() { python3 -c 'import sys,json; print("has claudeAiOauth:", "claudeAiOauth" in (json.load(sys.stdin) or {}))'; }
security find-generic-password -s "Claude Code-credentials" -w | has_token                    # -> false (service-only match: MCP-only)
security find-generic-password -s "Claude Code-credentials" -a "$(id -un)" -w | has_token      # -> true  (OS-username account)

Proposed fix

Make the keychain read account-aware, mirroring how Claude Code itself accesses the item:

  1. Keep the current service-only read (works on single-item keychains).
  2. If it yields no claudeAiOauth, read -s "Claude Code-credentials" -a <os.userInfo().username>.
  3. Existing suffixed-service and .credentials.json fallbacks unchanged.

This is minimal, matches Claude Code's own access pattern (no fragile reverse-engineering), and is safe on machines with a single credential item (the new read only fires when the service-only read misses). It resolves the OS-username-account case observed here; it deliberately doesn't guess non-username account strings.

I have this fix implemented with unit tests and can open a PR if you'd like — flagging first since #219 and #266 also touch per-account/auth-scoping and I don't want to collide.

Environment

Activity

  1. tim-fin commented on Jul 26, 2026

    @tim-fin

    Thanks for the detailed writeup — the mechanism you describe lines up with what I see reading src/utils/usage-fetch.ts.

    One extra detail that might be worth folding in: the existing multi-service fallback can't rescue this case, so the fix probably has to go a little deeper than swapping the lookup.

    getUsageToken() tries three things in order:

    return readUsageTokenFromMacKeychainService(MACOS_USAGE_CREDENTIALS_SERVICE)
        ?? readUsageTokenFromMacKeychainCandidates()
        ?? readUsageTokenFromCredentialsFile();

    The second step looks like it should catch a missed item, but I don't think it can here, for three reasons:

    1. parseMacKeychainCredentialCandidates() skips the exact service it would need — service === MACOS_USAGE_CREDENTIALS_SERVICE hits continue, so Claude Code-credentials itself is never a candidate.
    2. Its dedupe map is keyed by service, so two items differing only by acct would collapse to one entry even if step 1 didn't exclude them.
    3. readMacKeychainSecret() still shells out as find-generic-password -s <service> -w with no -a, so there's no way to ask for the other account.

    That mechanism seems designed for differently named services (the Claude Code-credentials-hashed case in usage-token.test.ts) rather than multiple accounts under one name — which is exactly the shape of the problem you hit. So it might need to track (service, account) pairs and read with -a <account> -s <service> -w, using dump-keychain to enumerate the accounts.

    Fair warning that I haven't reproduced the duplicate-item state on my own machine, so this is from reading the code rather than from a live repro — happy to be corrected if I've misread it.

    Are you planning to pick this one up? You know this area far better than I do, so I don't want to tread on your toes. If you'd rather not, I'm glad to take a run at it — and either way happy to test a patch on macOS.

  2. Cloudmancermedia commented on Jul 28, 2026

    @Cloudmancermedia

    I have noticed something that seems to have the same root cause but slightly different symptom.

    I run two Claude homes: ~/.claude, and ~/.claude-work via CLAUDE_CONFIG_DIR. The widgets don't error, but they render the wrong account's numbers. Work showed Wk 54% while that account was at 97%. Both homes showed an identical 5-hour reset.

    The cache is also stamped with the wrong token:

    ~/.cache/ccstatusline/usage.json  tokenHash: ae20…
    personal token (sha256[:16]):                ae20…   ← match
    work token     (sha256[:16]):                9dcc…
    

    getUsageToken() always asks for the plain service name. But Claude Code gives each home its own: Claude Code-credentials-<first 8 hex of sha256(CLAUDE_CONFIG_DIR)>. I checked, and sha256("/Users/<user>/.claude-work")[:8] is exactly the suffix on the deployed item. So a non-default home never reads its own credential.

    One minor correction: The issue says suffixed services are MCP-only and lack claudeAiOauth but this is not always the case. Here the suffixed one is the work home's credential:

    Claude Code-credentials          keys: ['claudeAiOauth']
    Claude Code-credentials-<hex>    keys: ['claudeAiOauth', 'mcpOAuth']
    

    Per-config-dir credentials and per-server MCP tokens look the same from the name alone. That makes the dump-keychain fallback a second way to land on the wrong account: it sorts by mdat (last-modified time), newest first, so it picks whichever home refreshed its token most recently.

    Proposed order, most specific first:

    1. "Claude Code-credentials-<sha256(resolve(configDir))[:8]>"  -a <username>
    2. same service, no -a
    3. "Claude Code-credentials"                                   -a <username>     ← this issue
    4. "Claude Code-credentials"                                   no -a             ← today
    5. mtime-sorted suffixed scan                                                    ← today
    6. <configDir>/.credentials.json                                                 ← today
    

    1–2 fire only in a non-default home; 3–6 are today's behavior plus your account-aware read, so single-account users are unaffected.

    I can't reproduce [No credentials] since I only have one item per service configured.

    #317 / #339

    Already addresses a similar bug in a different widget and seem to have the same setup I do.

    Seems like others have already started fixes related to this (#266) so just wanted to give some additional context on the issue. I have a local script working in my own setup that fixes this for me in the meantime.

  3. tim-fin commented on Jul 29, 2026

    @tim-fin

    I can confirm the two mechanisms here — one from the Claude Code binary, one from ccstatusline's source.

    The suffix scheme. The service-name builder in the current native binary (2.1.220), deobfuscated:

    function serviceName(e = "") {
      const override = process.env.CLAUDE_SECURESTORAGE_CONFIG_DIR;
      const plain = override !== undefined ? !override : !process.env.CLAUDE_CONFIG_DIR;
      const dir = override !== undefined ? override.normalize("NFC") : effectiveConfigDir();
      const suffix = plain ? "" : `-${createHash("sha256").update(dir).digest("hex").substring(0, 8)}`;
      return `Claude Code${OAUTH_FILE_SUFFIX}${e}${suffix}`;
    }

    So a suffixed service can indeed be a full per-home credential, exactly as your keychain listing shows — the suffix appears whenever a non-default config dir is active. Two details for whoever implements the lookup: the path is NFC-normalized before hashing (visible on the override branch at least), and a CLAUDE_SECURESTORAGE_CONFIG_DIR env var takes precedence over CLAUDE_CONFIG_DIR as the hash input when set (an empty value forces the plain name).

    The mtime sort. Confirmed in source: parseMacKeychainCredentialCandidates() orders candidates via sortMacKeychainCredentialCandidates(), newest mdat first — so the fallback scan does land on whichever home refreshed its token most recently, as you said.

    On implementability: step 1 of your ordering may be cheaper than it looks. ccstatusline already has getClaudeConfigDir() (src/utils/claude-settings.ts), which respects CLAUDE_CONFIG_DIR — the statusline process inherits Claude Code's environment, which is also what the #339 fix for #317 relies on — and the .credentials.json fallback (your step 6) already calls it. So the config-dir-aware read needs only the hash, no new plumbing.

    Also flagging in case you haven't seen it: #532 went up a few hours after your comment and implements the account-scoped read of the plain service name (your step 3, ordered after today's service-only read) — i.e. the [No credentials] case this issue is about. The config-dir-aware steps 1–2 look closer to the ground #266 is covering.

  4. nguerrier commented on Sep 1, 2026

    @nguerrier
    Contributor

    I hit the two-homes variant described above (~/.claude + ~/.claude-perso via CLAUDE_CONFIG_DIR): session/weekly were right (they come from the payload's rate_limits), but fable-weekly-usage showed the other account's number, and usage.json was stamped with the wrong token fingerprint — exactly what @Cloudmancermedia saw.

    I opened #573 for that part only: getUsageToken() now tries Claude Code-credentials-<sha256(configDir)[:8]> first (same service-name rule @tim-fin extracted from the binary, including the CLAUDE_SECURESTORAGE_CONFIG_DIR override and NFC normalization), then falls through to the existing chain. No behavior change when CLAUDE_CONFIG_DIR is unset. Cache scoping per profile is left to #266.

    If either of you wants to try it on macOS:

    git clone -b fix/usage-token-config-dir https://github.com/nguerrier/ccstatusline.git
    cd ccstatusline && bun install && bun run build
    # then point your statusLine command at:  node /path/to/ccstatusline/dist/ccstatusline.js

    With CLAUDE_CONFIG_DIR set, the Fable/Opus weekly widgets should now match /usage for that profile.

  5. twjohnwu commented on Sep 27, 2026

    @twjohnwu

    Another variant of the same root cause, with a misleading symptom: the first-match item can hold a stale but well-formed claudeAiOauth (an old login under a different account name). Its accessToken is expired, and /api/oauth/usage answers an expired token with 429 + a fixed one-hour retry-after, not 401. So the widget looks permanently rate-limited rather than [No credentials], while Claude Code itself (which reads with -a <os-username>) keeps working.

    Evidence on one machine, same minute:

      - service-only match (`-s ... -w`, old account, `expiresAt` in the past) → `429 retry-after=2989`
      - `-s ... -a "$(id -un)" -w` → `200`
    

    Probing did not extend the window (three hits in 10 min, same end time), and /logout + /login rewrote the current-account item but the service-only lookup still returned the old one.

    Two suggestions on top of the account-aware read: (1) check claudeAiOauth.expiresAt before using a token and skip the request when it's in the past; (2) don't treat a 429 from an expired token as a rate limit.

  6. nguerrier commented on Oct 4, 2026

    @nguerrier
    Contributor

    Thanks for the detailed repro. To clarify scope: #573 only changes the lookup when CLAUDE_CONFIG_DIR is set. It reads the profile's own suffixed item and no longer falls back to the plain service or the candidate scan. The default-profile case you describe (several items under the plain Claude Code-credentials service) is untouched by it; that's what #532's account-scoped read addresses.

    +1 on checking expiresAt before the request: it would also harden the suffixed path, which #573 still reads by service name only.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions