[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
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
timeoutbound 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_KEYis also set:x-api-keycarries that key, butAuthorization: Beareris still sent empty. A proxy that readsAuthorizationbeforex-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):
- 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:
``jsapiKeyHelper failed: ${h}
}catch(g){if(s!==e.epoch)return" ";let h=g instanceof Error?g.message:String(g);if(console.error(ge.red()),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()}," "}``
- The header builder treats that space as a key, because
" "is truthy. It setsBearer, which goes on the wire asAuthorization: Bearer:
``jsBearer ${g}
async function Y9(e,n){/*…*/h=gw()&&!r?void 0:a.ANTHROPIC_AUTH_TOKEN,g=h||await ZKe(n);if(g)return e.Authorization=,/*…*/}``
- Without
ANTHROPIC_API_KEY, the first-party client's API key resolves to the same cached helper value (let Ye=e||Mne(), which reachesfhr(){return Q9t.of(U().host).cache?.value??null}), sox-api-keyis sent blank too. - 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, wherefst(){let e=Q9t.of(U().host);e.epoch++,e.cache=null,e.inflight=null}. - 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++}, withcfr=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.
- Make
claudeavailable as/tmp/ccbug/bin/claude, for examplemkdir -p /tmp/ccbug/bin && ln -s "$(command -v claude)" /tmp/ccbug/bin/claude. Here it is the native 2.1.294 build. - Save the four files below into
/tmp/ccbug/scripts/andchmod +x /tmp/ccbug/scripts/*.sh. - Run
/tmp/ccbug/scripts/repro.sh A helper-fails-once.sh 18451. The script runsclaude -p "say ok"in a throwawayHOMEwhose only setting is theapiKeyHelper, with no other credential in the environment. - Read the
--- sinksection. The first request hasAuthorization: Bearerwith no token and an emptyx-api-key, sent after stderr already showsapiKeyHelper failed. The retry after the 401 carries the key from the helper's second run. - Run
/tmp/ccbug/scripts/repro.sh B helper-always-fails.sh 18452: three credential-less requests go out before the turn stops. - Run
/tmp/ccbug/scripts/repro.sh C helper-fails-once.sh 18453 env-test-key: the first request carries the environment key inx-api-keyand still an emptyAuthorization: Bearer. - 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 stimeoutand exits 124; Claude Code waits for it, then sends the request 9 ms later with an emptyAuthorization: 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 firstPartyin the debug log) withANTHROPIC_BASE_URLset. The header builder's call site (if(!Oe&&!co())je=await Y9(Ee,ve())) does not depend onANTHROPIC_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
apiKeyHelperinsettings.jsonandANTHROPIC_API_KEYalso set (case C). The helper is bounded at 3 s. In every logged turn where its run at CLI start timed out, stderr showedapiKeyHelper 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 hasBearerprefix.`, which LiteLLM raises when the bearer it extracts is empty, and retry 1 succeeded. Case D reproduces that shape locally. - Related, different defects: #83207 (
/statusdoes not show the helper's error) and #98838 (stored credentials overrideapiKeyHelper). #77048 also captured an emptyAuthorization: Beareron the wire, but with OAuth credentials andapiKeyHelperruled 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.