CLAUDE_CODE_OAUTH_TOKEN silently deletes macOS Keychain credentials on exit
Summary
Setting CLAUDE_CODE_OAUTH_TOKEN as an environment variable causes the CLI to silently delete the macOS Keychain credential entry ("Claude Code-credentials") on process exit. This breaks authentication for all other Claude Code sessions (VS Code extension, other terminals).
Environment
- Claude Code v2.1.81
- macOS Darwin 25.3.0 (Apple Silicon)
- Cursor (VS Code fork) + Terminal.app
Steps to Reproduce
- Confirm Keychain entry exists:
``bash``
security find-generic-password -a "$USER" -s "Claude Code-credentials"
# → returns entry
- Run a single
claude -pwith the env var:
``bash``
TOKEN=$(security find-generic-password -a "$USER" -w -s "Claude Code-credentials" | python3 -c "import sys,json; print(json.loads(sys.stdin.read())['claudeAiOauth']['accessToken'])")
CLAUDE_CODE_OAUTH_TOKEN="$TOKEN" claude -p "ping" --model haiku
- Check Keychain after:
``bash``
security find-generic-password -a "$USER" -s "Claude Code-credentials"
# → "The specified item could not be found in the keychain."
Expected Behavior
When CLAUDE_CODE_OAUTH_TOKEN is set, the CLI should bypass Keychain (which it does for reads) without modifying or deleting existing Keychain entries. The env var is documented as a way to provide a pre-authenticated token — it should be read-only with respect to credential storage.
Actual Behavior
The CLI deletes the Keychain entry on exit. This causes:
- VS Code extension loses auth → shows "How do you want to log in?" screen
- Other terminal sessions lose auth
- Any concurrent
claudeprocesses that rely on Keychain fail with 401
Source Code Analysis
From reverse-engineering cli.js v2.1.81:
- When
CLAUDE_CODE_OAUTH_TOKENis set, the credential getter returns{accessToken: env.CLAUDE_CODE_OAUTH_TOKEN, refreshToken: null, expiresAt: null}— correctly bypassing Keychain reads
- The credential store uses a fallback combiner (
FI7): Keychain primary → plaintext fallback (~/.claude/.credentials.json). The combiner has cleanup logic: "successful write to backend A → delete from backend B"
- The bug: During process lifecycle, some code path triggers a credential write to the plaintext file. The combiner's cleanup logic then fires: "successful plaintext write → delete Keychain entry." This deletion is incorrect when the env var is set, because:
- The process never needed Keychain in the first place
- Other processes/sessions depend on that Keychain entry
- The deletion is a destructive side effect of a read-only operation
Impact
This bug is particularly severe for batch workflows. Users running parallel claude -p processes (e.g., for classification tasks) who set CLAUDE_CODE_OAUTH_TOKEN to avoid the Keychain race condition (#24317) end up destroying the credentials that their VS Code extension session depends on.
It creates a catch-22:
- Without
CLAUDE_CODE_OAUTH_TOKEN: Keychain race condition kicks out the extension (#24317) - With
CLAUDE_CODE_OAUTH_TOKEN: Keychain entry gets deleted, kicks out the extension
Proposed Fix
When CLAUDE_CODE_OAUTH_TOKEN is set (or EH8() returns true for setup-token mode):
- Skip all credential writes (not just reads) — the env var token is ephemeral and shouldn't persist
- Skip the fallback combiner's cleanup logic entirely
- Never call
security delete-generic-passwordfor the main credentials entry
Pseudocode:
// In the credential write path:
if (process.env.CLAUDE_CODE_OAUTH_TOKEN || isSetupTokenMode()) {
return; // Don't write, don't delete — env var mode is read-only
}
Workaround
We built a wrapper script that:
- Extracts the token from
~/.claude/.credentials.json(not Keychain) - Passes
CLAUDE_CODE_OAUTH_TOKENonly to the child process - Restores the Keychain entry after batch completion if deleted
#!/bin/bash
# claude-batch — wrapper for parallel claude -p without auth kickout
CREDS_FILE="$HOME/.claude/.credentials.json"
extract_token() {
python3 -c "
import json, sys
with open('$CREDS_FILE') as f:
data = json.load(f)
print(data['claudeAiOauth']['accessToken'])
"
}
restore_keychain() {
if security find-generic-password -a "$USER" -s "Claude Code-credentials" > /dev/null 2>&1; then
return 0 # already exists
fi
security add-generic-password -a "$USER" -s "Claude Code-credentials" \
-w "$(cat "$CREDS_FILE")" 2>/dev/null
}
TOKEN=$(extract_token) || exit 1
CLAUDE_CODE_OAUTH_TOKEN="$TOKEN" claude "$@"
Usage: claude-batch -p "prompt" --model sonnet instead of claude -p.
After batch: call restore_keychain.
Tested: 30 parallel calls from Cursor Bash tool, 30/30 success, extension auth preserved.
Related Issues
- #24317 — OAuth token refresh race condition (primary tracking issue)
- #37203 — Background agents trigger 'Not logged in'
- #37324 — Session token expires with concurrent subagents
- #37468 — 5 parallel agents, 3 hours of work lost
- #30337 — macOS Keychain 4KB buffer credential corruption (fixed v2.1.78)
This issue has 7 comments on GitHub. Read the full discussion on GitHub ↗