CLAUDE_CODE_OAUTH_TOKEN silently deletes macOS Keychain credentials on exit

Resolved 💬 7 comments Opened Mar 22, 2026 by LARIkoz Closed Apr 21, 2026

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

  1. Confirm Keychain entry exists:

``bash
security find-generic-password -a "$USER" -s "Claude Code-credentials"
# → returns entry
``

  1. Run a single claude -p with 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
``

  1. 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 claude processes that rely on Keychain fail with 401

Source Code Analysis

From reverse-engineering cli.js v2.1.81:

  1. When CLAUDE_CODE_OAUTH_TOKEN is set, the credential getter returns {accessToken: env.CLAUDE_CODE_OAUTH_TOKEN, refreshToken: null, expiresAt: null}correctly bypassing Keychain reads
  1. 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"
  1. 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):

  1. Skip all credential writes (not just reads) — the env var token is ephemeral and shouldn't persist
  2. Skip the fallback combiner's cleanup logic entirely
  3. Never call security delete-generic-password for 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:

  1. Extracts the token from ~/.claude/.credentials.json (not Keychain)
  2. Passes CLAUDE_CODE_OAUTH_TOKEN only to the child process
  3. 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)

View original on GitHub ↗

This issue has 7 comments on GitHub. Read the full discussion on GitHub ↗