[BUG] A failed apiKeyHelper run with no cached key sends the request with an empty `Authorization: Bearer` and blank `x-api-key` instead of stopping with the helper error

Status Open
Reported on v2.1.294
Maintainer reply None cached
Activity 0 comments · opened Oct 8, 2026

Preflight Checklist

  • [x] I have searched existing issues and this hasn't been reported yet
  • [x] This is a single bug report (please file separate reports for different bugs)
  • [x] I am using the latest version of Claude Code

What's Wrong?

When the apiKeyHelper command fails and Claude Code holds no earlier value from it, Claude Code still sends the API request, with no credential in it. The helper-failure path caches and returns a single space as the key, so the request goes out with Authorization: Bearer and nothing after it, and with an empty x-api-key. The server answers 401; the 401 handler clears the helper cache and retries, which runs the helper again.

Claude Code knows the helper failed before it sends that request: it prints apiKeyHelper failed: exited 1: … to stderr and logs Error getting API key from apiKeyHelper 108 ms before the request leaves in case A; in case B each retry logs the failure 3 ms before sending (debug logs below).

Consequences, all reproduced on 2.1.294 against a local HTTP sink (no Anthropic traffic):

  • Helper fails once (a timeout, a slow secret store at startup): the first request carries an empty bearer and gets a 401, then the retry runs the helper again and succeeds (cases A and C; case D is the same with a helper that exceeds its own timeout bound and exits 124). Every such turn costs a rejected request and a retry delay.
  • Helper keeps failing: three requests with no credential go out before Claude Code stops with Your apiKeyHelper script is failing.
  • ANTHROPIC_API_KEY is also set: x-api-key carries that key, but Authorization: Bearer is still sent empty. A proxy that reads Authorization before x-api-key (LiteLLM does) rejects the request even though a valid key is on it.

Cause, read in the shipped 2.1.294 bundle (the current release; the minified names are from that build):

  1. The helper runner's catch branch returns the earlier cached key if there is one (outside a forced refresh). Otherwise it records the failure and caches a single space as the key, then returns it:

``js
}catch(g){if(s!==e.epoch)return" ";let h=g instanceof Error?g.message:String(g);if(console.error(ge.red(
apiKeyHelper failed: ${h})),t(Error getting API key from apiKeyHelper: ${h},{level:"error"}),!r&&e.cache&&e.cache.value!==" ")return e.cache={...e.cache,timestamp:Date.now()},e.cache.value;if(e.lastFailure=h,qle()){/* status-panel error */}return e.cache={value:" ",timestamp:Date.now()}," "}
``

  1. The header builder treats that space as a key, because " " is truthy. It sets Bearer , which goes on the wire as Authorization: Bearer:

``js
async function Y9(e,n){/*…*/h=gw()&&!r?void 0:a.ANTHROPIC_AUTH_TOKEN,g=h||await ZKe(n);if(g)return e.Authorization=
Bearer ${g},/*…*/}
``

  1. Without ANTHROPIC_API_KEY, the first-party client's API key resolves to the same cached helper value (let Ye=e||Mne(), which reaches fhr(){return Q9t.of(U().host).cache?.value??null}), so x-api-key is sent blank too.
  2. On any 401 the retry predicate clears the helper cache and retries, so the next attempt runs the helper again: if(e.status===401)return fst(),!0, where fst(){let e=Q9t.of(U().host);e.epoch++,e.cache=null,e.inflight=null}.
  3. The retry loop gives up on a failing helper only after two such retries: if(Jt instanceof xt&&(Jt.status===401||Jt.status===403)&&Vn()&&qle()&&pst()!==null){if(xe>=cfr)throw m("api_request","api_request_api_key_helper_failed"),new qc(Jt,h);xe++}, with cfr=2. That makes three requests in total.

What Should Happen?

A failed apiKeyHelper run with no usable key stops the request before it is sent. Claude Code ends the turn with the helper's error, as it already does after the third 401 (Your apiKeyHelper script is failing, with apiKeyHelper failed: exited 1: … on stderr), or runs the helper again before sending. No request leaves with a blank Authorization or x-api-key, and a failure is never cached as a key value.

Error Messages/Logs

# Case A: helper fails on its first run, succeeds afterwards
$ repro.sh A helper-fails-once.sh 18451
===== case A
exit=0
--- stdout
ok
--- stderr
apiKeyHelper failed: exited 1: simulated failure: secret store did not answer
(one unrelated auto-mode billing notice about the non-Anthropic base URL omitted)
--- sink          # raw header bytes after the colon
13:34:58.279Z POST /v1/messages?beta=true HTTP/1.1 authorization_raw=b' Bearer' x_api_key_raw=b' '
13:34:58.280Z   -> 401
13:34:59.296Z POST /v1/messages?beta=true HTTP/1.1 authorization_raw=b' Bearer local-test-key' x_api_key_raw=b' local-test-key'
13:34:59.297Z   -> 200
--- helper runs
13:34:58.027Z helper run 1
13:34:59.292Z helper run 2

# Case B: helper always fails
$ repro.sh B helper-always-fails.sh 18452
===== case B
exit=1
--- stdout
Your apiKeyHelper script is failing · This usually means you need to re-authenticate with your provider · Run /status to see the script's error output
--- stderr
apiKeyHelper failed: exited 1: simulated failure: secret store did not answer
apiKeyHelper failed: exited 1: simulated failure: secret store did not answer
apiKeyHelper failed: exited 1: simulated failure: secret store did not answer
--- sink
13:35:00.907Z POST /v1/messages?beta=true HTTP/1.1 authorization_raw=b' Bearer' x_api_key_raw=b' '
13:35:00.907Z   -> 401
13:35:01.924Z POST /v1/messages?beta=true HTTP/1.1 authorization_raw=b' Bearer' x_api_key_raw=b' '
13:35:01.925Z   -> 401
13:35:02.936Z POST /v1/messages?beta=true HTTP/1.1 authorization_raw=b' Bearer' x_api_key_raw=b' '
13:35:02.937Z   -> 401
--- helper runs
13:35:00.656Z helper run 1
13:35:01.920Z helper run 2
13:35:02.932Z helper run 3

# Case C: helper fails once, ANTHROPIC_API_KEY=env-test-key also set
$ repro.sh C helper-fails-once.sh 18453 env-test-key
===== case C
exit=0
--- stdout
ok
--- stderr
apiKeyHelper failed: exited 1: simulated failure: secret store did not answer
(one unrelated auto-mode billing notice about the non-Anthropic base URL omitted)
--- sink
13:35:04.514Z POST /v1/messages?beta=true HTTP/1.1 authorization_raw=b' Bearer' x_api_key_raw=b' env-test-key'
13:35:04.514Z   -> 401
13:35:05.137Z POST /v1/messages?beta=true HTTP/1.1 authorization_raw=b' Bearer local-test-key' x_api_key_raw=b' env-test-key'
13:35:05.138Z   -> 200
--- helper runs
13:35:04.271Z helper run 1
13:35:05.133Z helper run 2

# Case D: helper times out once (its own 1 s `timeout`, exit 124), ANTHROPIC_API_KEY=env-test-key also set
$ repro.sh D helper-times-out-once.sh 18454 env-test-key
===== case D
exit=0
--- stdout
ok
--- stderr
apiKeyHelper failed: exited 124: simulated: secret store did not answer within 1s
(one unrelated auto-mode billing notice about the non-Anthropic base URL omitted)
--- sink
14:09:18.911Z POST /v1/messages?beta=true HTTP/1.1 authorization_raw=b' Bearer' x_api_key_raw=b' env-test-key'
14:09:18.912Z   -> 401
14:09:19.532Z POST /v1/messages?beta=true HTTP/1.1 authorization_raw=b' Bearer local-test-key' x_api_key_raw=b' env-test-key'
14:09:19.533Z   -> 200
--- helper runs
14:09:17.900Z helper run 1 start
14:09:18.902Z helper run 1 exit 124
14:09:19.528Z helper run 2 start

Steps to Reproduce

No Anthropic account or API call is needed: ANTHROPIC_BASE_URL points at a local sink that logs the raw Authorization and x-api-key bytes. The sink answers 401 unless the bearer is local-test-key, and otherwise returns a minimal Messages stream.

  1. Make claude available as /tmp/ccbug/bin/claude, for example mkdir -p /tmp/ccbug/bin && ln -s "$(command -v claude)" /tmp/ccbug/bin/claude. Here it is the native 2.1.294 build.
  2. Save the four files below into /tmp/ccbug/scripts/ and chmod +x /tmp/ccbug/scripts/*.sh.
  3. Run /tmp/ccbug/scripts/repro.sh A helper-fails-once.sh 18451. The script runs claude -p "say ok" in a throwaway HOME whose only setting is the apiKeyHelper, with no other credential in the environment.
  4. Read the --- sink section. The first request has Authorization: Bearer with no token and an empty x-api-key, sent after stderr already shows apiKeyHelper failed. The retry after the 401 carries the key from the helper's second run.
  5. Run /tmp/ccbug/scripts/repro.sh B helper-always-fails.sh 18452: three credential-less requests go out before the turn stops.
  6. Run /tmp/ccbug/scripts/repro.sh C helper-fails-once.sh 18453 env-test-key: the first request carries the environment key in x-api-key and still an empty Authorization: Bearer.
  7. Run /tmp/ccbug/scripts/repro.sh D helper-times-out-once.sh 18454 env-test-key: the helper's first run hits its own 1 s timeout and exits 124; Claude Code waits for it, then sends the request 9 ms later with an empty Authorization: Bearer.

<details><summary><code>sink.py</code>: local header sink</summary>

# Local HTTP sink: logs the raw Authorization / x-api-key header bytes of every request,
# answers 401 unless the bearer is the key the helper prints on success, else a minimal
# Messages API stream. No upstream calls.
import asyncio, json, sys, datetime
GOOD = "local-test-key"
PORT = int(sys.argv[1]); LOG = open(sys.argv[2], "a", buffering=1)
def now(): return datetime.datetime.now(datetime.timezone.utc).strftime("%H:%M:%S.%f")[:-3] + "Z"
SSE = [
 ("message_start", {"type":"message_start","message":{"id":"msg_local","type":"message","role":"assistant","model":"local","content":[],"stop_reason":None,"stop_sequence":None,"usage":{"input_tokens":1,"output_tokens":1}}}),
 ("content_block_start", {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}),
 ("content_block_delta", {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"ok"}}),
 ("content_block_stop", {"type":"content_block_stop","index":0}),
 ("message_delta", {"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":None},"usage":{"output_tokens":1}}),
 ("message_stop", {"type":"message_stop"}),
]
async def handle(r, w):
    try:
        while True:
            head = await r.readuntil(b"\r\n\r\n")
            lines = head.split(b"\r\n")
            reqline = lines[0].decode()
            hdrs = {}
            for l in lines[1:]:
                if b":" in l:
                    k, v = l.split(b":", 1); hdrs[k.strip().lower()] = v
            n = int(hdrs.get(b"content-length", b"0").strip() or 0)
            body = await r.readexactly(n) if n else b""
            auth = hdrs.get(b"authorization"); xak = hdrs.get(b"x-api-key")
            LOG.write(f"{now()} {reqline} authorization_raw={auth!r} x_api_key_raw={xak!r}\n")
            tok = auth.strip()[len(b"Bearer"):].strip().decode() if auth is not None else None
            if not reqline.startswith("POST /v1/messages"):
                st, ctype, payload = 404, "application/json", json.dumps({"type":"error","error":{"type":"not_found_error","message":"not found"}}).encode()
            elif tok != GOOD:
                st, ctype, payload = 401, "application/json", json.dumps({"type":"error","error":{"type":"authentication_error","message":"empty or unknown bearer token"}}).encode()
            else:
                req = json.loads(body or b"{}")
                if req.get("stream"):
                    st, ctype = 200, "text/event-stream"
                    payload = "".join(f"event: {e}\ndata: {json.dumps(d)}\n\n" for e, d in SSE).encode()
                else:
                    st, ctype = 200, "application/json"
                    payload = json.dumps({"id":"msg_local","type":"message","role":"assistant","model":"local","content":[{"type":"text","text":"ok"}],"stop_reason":"end_turn","stop_sequence":None,"usage":{"input_tokens":1,"output_tokens":1}}).encode()
            LOG.write(f"{now()}   -> {st}\n")
            w.write(f"HTTP/1.1 {st} X\r\nContent-Type: {ctype}\r\nContent-Length: {len(payload)}\r\nrequest-id: req_local\r\n\r\n".encode() + payload)
            await w.drain()
    except (asyncio.IncompleteReadError, ConnectionResetError):
        pass
    finally:
        w.close()
async def main():
    s = await asyncio.start_server(handle, "127.0.0.1", PORT)
    async with s: await s.serve_forever()
asyncio.run(main())

</details>

<details><summary><code>helper-fails-once.sh</code> and <code>helper-always-fails.sh</code></summary>

#!/bin/sh
# Fails on its first run (as a helper that times out would), prints a key on every later run.
C="$1"
n=$(cat "$C" 2>/dev/null || echo 0); n=$((n+1)); echo "$n" > "$C"
echo "$(date -u +%H:%M:%S.%3NZ) helper run $n" >> "$C.log"
if [ "$n" -eq 1 ]; then echo "simulated failure: secret store did not answer" >&2; exit 1; fi
echo local-test-key
#!/bin/sh
C="$1"
n=$(cat "$C" 2>/dev/null || echo 0); n=$((n+1)); echo "$n" > "$C"
echo "$(date -u +%H:%M:%S.%3NZ) helper run $n" >> "$C.log"
echo "simulated failure: secret store did not answer" >&2; exit 1

</details>

<details><summary><code>helper-times-out-once.sh</code></summary>

#!/bin/sh
# First run: the key read exceeds its 1 s bound (exit 124, like `timeout`); later runs print the key.
C="$1"
n=$(cat "$C" 2>/dev/null || echo 0); n=$((n+1)); echo "$n" > "$C"
echo "$(date -u +%H:%M:%S.%3NZ) helper run $n start" >> "$C.log"
if [ "$n" -eq 1 ]; then
  timeout 1 sleep 5; rc=$?
  echo "$(date -u +%H:%M:%S.%3NZ) helper run $n exit $rc" >> "$C.log"
  echo "simulated: secret store did not answer within 1s" >&2; exit $rc
fi
echo local-test-key

</details>

<details><summary><code>repro.sh</code></summary>

#!/bin/bash
# Usage: repro.sh <case> <helper> <port> [ANTHROPIC_API_KEY value]
# Runs `claude -p` in a throwaway HOME whose only setting is an apiKeyHelper, against the local sink.
S=$1; H=$2; P=$3; K=${4:-}
W=/tmp/ccbug; R=$W/run$S
rm -rf $R; mkdir -p $R/fakehome/.claude $R/work
printf '{ "apiKeyHelper": "%s/scripts/%s %s/count" }\n' $W $H $R > $R/fakehome/.claude/settings.json
python3 -I $W/scripts/sink.py $P $R/sink.log & SP=$!; sleep 1
( cd $R/work && env -i PATH=$W/bin:/usr/bin:/bin HOME=$R/fakehome TERM=dumb ANTHROPIC_BASE_URL=http://127.0.0.1:$P \
    CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1 DISABLE_AUTOUPDATER=1 ${K:+ANTHROPIC_API_KEY=$K} \
    claude -p "say ok" --debug-file $R/debug.log < /dev/null > $R/stdout.txt 2> $R/stderr.txt; echo "exit=$?" > $R/exit.txt )
kill $SP; wait $SP 2>/dev/null
echo "===== case $S"; cat $R/exit.txt; echo "--- stdout"; cat $R/stdout.txt; echo "--- stderr"; cat $R/stderr.txt
echo "--- sink"; cat $R/sink.log; echo "--- helper runs"; cat $R/count.log

</details>

Claude Model

Opus

Is this a regression?

I don't know

Last Working Version

_No response_

Claude Code Version

2.1.294 (Claude Code)

Platform

Anthropic API

Operating System

Other Linux

Terminal/Shell

Non-interactive/CI environment

Additional Information

  • Platform: Linux arm64, native install, claude -p. The requests use the first-party provider path (dispatching to firstParty in the debug log) with ANTHROPIC_BASE_URL set. The header builder's call site (if(!Oe&&!co())je=await Y9(Ee,ve())) does not depend on ANTHROPIC_BASE_URL, so the request is built the same way without it.
  • First seen in production with Claude Code 2.1.281 behind a LiteLLM proxy, with apiKeyHelper in settings.json and ANTHROPIC_API_KEY also set (case C). The helper is bounded at 3 s. In every logged turn where its run at CLI start timed out, stderr showed apiKeyHelper failed: exited 124: <helper message: the secret store did not answer within 3s>. The proxy then rejected attempt 1 with `401 Authentication Error, Malformed API Key passed in. Ensure Key has Bearer prefix.`, which LiteLLM raises when the bearer it extracts is empty, and retry 1 succeeded. Case D reproduces that shape locally.
  • Related, different defects: #83207 (/status does not show the helper's error) and #98838 (stored credentials override apiKeyHelper). #77048 also captured an empty Authorization: Bearer on the wire, but with OAuth credentials and apiKeyHelper ruled out.

<details><summary>Debug log, case A (<code>--debug-file</code>; config-file writes, plugin, skill, LSP, MCP and other startup lines removed)</summary>

2026-10-08T13:34:58.170Z [ERROR] Error getting API key from apiKeyHelper: [REDACTED] 1: simulated failure: secret store did not answer
2026-10-08T13:34:58.212Z [DEBUG] [engine] turn 1 start
2026-10-08T13:34:58.266Z [DEBUG] attribution header x-anthropic-billing-header: cc_version=2.1.294.8e4; cc_entrypoint=sdk-cli;
2026-10-08T13:34:58.276Z [DEBUG] [API:timing] dispatching to firstParty model=claude-opus-5-5
2026-10-08T13:34:58.278Z [DEBUG] [API REQUEST] /v1/messages source=sdk
2026-10-08T13:34:58.282Z [ERROR] API error (attempt 1/11): 401 401 {"type":"error","error":{"type":"authentication_error","message":"empty or unknown bearer token"}}
2026-10-08T13:34:59.295Z [DEBUG] [API:timing] dispatching to firstParty model=claude-opus-5-5
2026-10-08T13:34:59.296Z [DEBUG] [API REQUEST] /v1/messages source=sdk
2026-10-08T13:34:59.299Z [DEBUG] Stream started - received first chunk
2026-10-08T13:34:59.300Z [DEBUG] [API:timing] first byte after 6ms
2026-10-08T13:34:59.306Z [DEBUG] [server-classifier] the platform gave no classification for this request (server_no_result); treating that as this deployment's answer: auto mode classifies locally for the rest of this session and stops sending the classifier context
2026-10-08T13:34:59.314Z [DEBUG] [engine] turn 1 end (turns=1 usage in=1 out=1 cost=$0.0000 api=1040ms stop=end_turn resultLen=2)

</details>

<details><summary>Debug log, case B (same filter)</summary>

2026-10-08T13:35:00.798Z [ERROR] Error getting API key from apiKeyHelper: [REDACTED] 1: simulated failure: secret store did not answer
2026-10-08T13:35:00.839Z [DEBUG] [engine] turn 1 start
2026-10-08T13:35:00.894Z [DEBUG] attribution header x-anthropic-billing-header: cc_version=2.1.294.8e4; cc_entrypoint=sdk-cli;
2026-10-08T13:35:00.903Z [DEBUG] [API:timing] dispatching to firstParty model=claude-opus-5-5
2026-10-08T13:35:00.905Z [DEBUG] [API REQUEST] /v1/messages source=sdk
2026-10-08T13:35:00.909Z [ERROR] API error (attempt 1/11): 401 401 {"type":"error","error":{"type":"authentication_error","message":"empty or unknown bearer token"}}
2026-10-08T13:35:01.921Z [ERROR] Error getting API key from apiKeyHelper: [REDACTED] 1: simulated failure: secret store did not answer
2026-10-08T13:35:01.923Z [DEBUG] [API:timing] dispatching to firstParty model=claude-opus-5-5
2026-10-08T13:35:01.924Z [DEBUG] [API REQUEST] /v1/messages source=sdk
2026-10-08T13:35:01.925Z [ERROR] API error (attempt 2/11): 401 401 {"type":"error","error":{"type":"authentication_error","message":"empty or unknown bearer token"}}
2026-10-08T13:35:02.933Z [ERROR] Error getting API key from apiKeyHelper: [REDACTED] 1: simulated failure: secret store did not answer
2026-10-08T13:35:02.935Z [DEBUG] [API:timing] dispatching to firstParty model=claude-opus-5-5
2026-10-08T13:35:02.936Z [DEBUG] [API REQUEST] /v1/messages source=sdk
2026-10-08T13:35:02.937Z [ERROR] API error (attempt 3/11): 401 401 {"type":"error","error":{"type":"authentication_error","message":"empty or unknown bearer token"}}
2026-10-08T13:35:02.938Z [ERROR] Error in API request: 401 {"type":"error","error":{"type":"authentication_error","message":"empty or unknown bearer token"}}
2026-10-08T13:35:02.938Z [ERROR] API auth_error: empty or unknown bearer token
2026-10-08T13:35:02.945Z [DEBUG] [engine] turn 1 end (turns=1 usage in=0 out=0 cost=$0.0000 api=0ms stop=stop_sequence resultLen=0)

</details>

<details><summary>Debug log, case D (same filter)</summary>

2026-10-08T14:09:18.075Z [DEBUG] [engine] turn 1 start
2026-10-08T14:09:18.129Z [DEBUG] attribution header x-anthropic-billing-header: cc_version=2.1.294.8e4; cc_entrypoint=sdk-cli;
2026-10-08T14:09:18.904Z [ERROR] Error getting API key from apiKeyHelper: [REDACTED] 124: simulated: secret store did not answer within 1s
2026-10-08T14:09:18.907Z [DEBUG] [API:timing] dispatching to firstParty model=claude-opus-5-5
2026-10-08T14:09:18.910Z [DEBUG] [API REQUEST] /v1/messages source=sdk
2026-10-08T14:09:18.920Z [ERROR] API error (attempt 1/11): 401 401 {"type":"error","error":{"type":"authentication_error","message":"empty or unknown bearer token"}}
2026-10-08T14:09:19.531Z [DEBUG] [API:timing] dispatching to firstParty model=claude-opus-5-5
2026-10-08T14:09:19.532Z [DEBUG] [API REQUEST] /v1/messages source=sdk
2026-10-08T14:09:19.535Z [DEBUG] Stream started - received first chunk
2026-10-08T14:09:19.535Z [DEBUG] [API:timing] first byte after 6ms
2026-10-08T14:09:19.549Z [DEBUG] [engine] turn 1 end (turns=1 usage in=1 out=1 cost=$0.0000 api=1412ms stop=end_turn resultLen=2)

</details>

The debug log masks the word exited in its own Error getting API key from apiKeyHelper line as [REDACTED]; stderr shows the full text.

View original on GitHub ↗