Keychain password prompt loop on macOS: credentials read by forking /usr/bin/security
Summary
On macOS, Claude Code reads its stored OAuth credentials by shelling out to /usr/bin/security instead of calling the Security framework in-process. Because the keychain ACL then sees a generic Apple tool as the requesting client — not Claude Code — macOS shows a "security wants to access key 'Claude Code-credentials'" password prompt. Three compounding factors turn this into an unusable loop:
- "Always Allow" does not persist on this item, so the prompt returns on every read.
- Every token refresh rewrites the item and resets its ACL, wiping even a manually repaired grant (~every 8h).
- Concurrent Claude Code processes each fork their own
security, so the prompts arrive 10+ at a time, and orphaned dialogs can be left on screen that cannot be dismissed at all.
I have been asked for my login password more than 10 times in a row.
Environment
- macOS 15.7.5 (24G624), Apple Silicon
- Claude Code 2.1.241 (bundled in Claude.app) — same string also present in 2.1.236 standalone CLI
- Keychain item: service
Claude Code-credentials, login keychain
What Claude Code is doing
Traced the process tree while a prompt was on screen:
pid=67571 security find-generic-password -a <user> -w -s Claude Code-credentials
parent(67567) = .../Claude/claude-code/2.1.241/claude.app/Contents/MacOS/claude
pid=67573 security find-generic-password -a <user> -w -s Claude Code-credentials
parent(67565) = .../claude.app/Contents/MacOS/claude
pid=67575 ... parent(67569) = .../claude.app/Contents/MacOS/claude
pid=67591, 67592 ... parent(67457) = .../claude.app/Contents/MacOS/claude
Each read forks its own security process, and each one blocks on its own dialog.
strings on the binary confirms the CLI path is used for both read and write:find-generic-password (12 occurrences), add-generic-password -U -a "…" -s "…" -X "…", security delete-generic-password -a.
Why the prompt appears at all
securityd evaluates the ACL against the calling process, which is /usr/bin/security, not Claude Code:
[securityd:integrity] ACL partition mismatch: client apple-tool: ACL (
[securityd:integrity] asking user about XARA partition for 'apple-tool:'
[securityd:kcacl] displaying keychain prompt for /usr/bin/security(67573)
The item was created by Claude Code (cdat = 2026-05-13). Had Claude Code read it in-process, it would already be on the item's own ACL and would never prompt.
Why "Always Allow" doesn't help
securityd says it accepts and stores the grant:
23:39:27 [kcacl] user approved 'always allow' for /usr/bin/security
23:39:27 [integrity] adding XARA partition 'apple-tool:' to list
23:40:38 [kcacl] user approved 'always allow' for /usr/bin/security <- asked again
23:40:38 [integrity] adding XARA partition 'apple-tool:' to list
But ~/Library/Keychains/login.keychain-db was never written — its mtime stayed at 23:30:26 across both approvals. The grant only lives in the securityd session, so the next forked security process asks again.
Why the manual repair only lasts one token refresh
Repairing the partition list directly does persist:
security set-generic-password-partition-list -s "Claude Code-credentials" -a <user> \
-S apple-tool:,apple: ~/Library/Keychains/login.keychain-db
Applied at 09:13:55 → keychain file written → zero prompts for the next 11 hours (verified in securityd logs).
Then:
mdat before: 20260826022453Z
mdat after: 20260826183005Z (= 14:30 local, a token refresh)
cdat: 20260513042620Z (unchanged — in-place update, not a recreate)
The in-place rewrite reset the item's ACL and partition list, and the prompts came straight back. Access tokens last 8h, so this recurs 2–3× per day.
Orphaned, undismissable dialogs
When a forked security exits while its dialog is still up, SecurityAgent keeps the window on screen with no client to receive the answer. Clicking Allow/Cancel does nothing. The only way out was kill -9 on SecurityAgent (SIGTERM was ignored). At one point ~20 stale prompts had accumulated; securityd later flushed them all at once:
[kcacl] user did not approve 'allow' for /usr/bin/security(25267): CssmError 225
[kcacl] user did not approve 'allow' for /usr/bin/security(26901): CssmError 225
... (20+ long-dead PIDs)
Controlled comparison
I maintain a small menu-bar app that reads the same keychain item. It originally had the identical shell-out design, and I switched it to in-process SecItemCopyMatching:
| Reader | Method | Result |
|---|---|---|
| My app (before) | fork /usr/bin/security | prompted on every read |
| My app (after) | in-process SecItemCopyMatching | one grant, silent thereafter |
| Claude Code | fork /usr/bin/security | prompts repeatedly, forever |
Same machine, same user, same keychain item — the only variable is the shell-out. This is not environment-specific.
Suggested fixes, in order of impact
- Read and write the keychain in-process (
SecItemCopyMatching/SecItemUpdate) via a native module. The ACL subject becomes Claude Code itself, which created the item — no prompt, ever. This alone resolves the issue. - Preserve the ACL across token refreshes. The current rewrite path resets it, which is what makes any user-side repair temporary.
- Don't fork one
securityper process. A single cached read shared across concurrent sessions would turn a 10-dialog storm into at most one. - Don't leave orphaned dialogs. If the credential read is abandoned, cancel the pending keychain query so SecurityAgent tears the window down.
Related security note
The write path prefers stdin but falls back to argv — the binary contains:
B JSON) exceeds security -i stdin limit; using argv
On that fallback the credential blob (including the long-lived refresh token) lands in the process's argv, which is readable by any process on the machine via ps. Moving writes in-process (fix #1) removes this too.