Skip to content

CLAUDE_CODE_OAUTH_TOKEN silently deletes macOS Keychain credentials on exit #37512

Description

@LARIkoz

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:

    security find-generic-password -a "$USER" -s "Claude Code-credentials"
    # → returns entry
  2. Run a single claude -p with the env var:

    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
  3. Check Keychain after:

    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

  2. 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"

  3. 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:

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    area:authbugSomething isn't workinghas reproHas detailed reproduction stepsplatform:macosIssue specifically occurs on macOSstaleIssue is inactive

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions