Summary
When claude-code (spawned by @anthropic-ai/claude-agent-sdk or invoked directly) opens a single streaming HTTP request whose response streams for longer than the current bearer token's remaining TTL, the auth layer kills the connection when the token expires. There is no mechanism to renew auth on an already-open stream — apiKeyHelper is only consulted between HTTP requests. Consumers using short-lived bearers (e.g. via apiKeyHelper) hit this reliably on any single turn that generates a large model response.
Surfaces two ways depending on when the auth check trips:
Response stalled mid-stream — an is_error: true result when the auth layer kills the streaming connection itself.
Failed to authenticate. API Error: 403 {"error":["The token is expired"]} — when a follow-up HTTP call after result=success reuses a token that expired during the preceding stream.
Environment
- Node: 24 (also observed on 22)
- SDK:
@anthropic-ai/claude-agent-sdk@0.3.202
- Bundled CLI:
claude version 2.1.202 (per SDK manifest.json)
- Model:
claude-sonnet-4-6
- Auth path:
apiKeyHelper in ~/.claude/settings.json minting short-lived bearer tokens (~300 s TTL) against a bearer-validating proxy that fronts the Anthropic-compatible API. CLAUDE_CODE_API_KEY_HELPER_TTL_MS=60000 is set on the environment; apiKeyHelper DOES fire on the ~60 s cadence between requests (verified — see cadence evidence below).
Reproducer
The failure requires two things: (1) an apiKeyHelper that returns tokens with short TTL that the auth proxy strictly enforces, and (2) a prompt that forces the model to produce a single long streaming response. We built a minimal repro on GitHub Actions that reliably fires the failure in ~7 min. Sanitised setup:
1. ~/.claude/settings.json:
{
"apiKeyHelper": "/path/to/helper.sh",
"env": { "ANTHROPIC_BASE_URL": "https://<bearer-validating-proxy>/<instance>" }
}
2. helper.sh — the specific implementation of apiKeyHelper. Ours mints a token via a short-lived OIDC endpoint on every invocation and logs exp from the decoded JWT so we can correlate helper calls against stream timing:
#!/usr/bin/env bash
set -euo pipefail
LOG=/tmp/repro/helper.log
token=$(<mint fresh short-TTL bearer>)
exp=$(echo "$token" | cut -d. -f2 | base64 -d 2>/dev/null | jq -r '.exp // 0')
now=$(date +%s)
printf '%s ppid=%d exp=%s ttl_s=%s\n' "$(date +%s.%N)" "$PPID" "$exp" "$((exp - now))" >> "$LOG"
printf '%s' "$token"
3. Node driver — calls the SDK directly, no other wrappers:
import { query } from '@anthropic-ai/claude-agent-sdk';
const prompt = [
`You are working in ./workdir. There are 40 fixture .md files.`,
`STEP 1 — Read the first 40 fixtures sequentially.`,
`STEP 2 — Write ONE massive synthesis document to synthesis.md via a SINGLE Write tool call.`,
`The document MUST be at least 15,000 words covering trade-offs, history, and production caveats`,
`for each of the topics referenced in the fixtures. Do the entire document in ONE Write call.`,
].join('\n');
const iter = query({
prompt,
options: {
cwd: process.cwd(),
env: process.env,
permissionMode: 'acceptEdits',
tools: ['Read','Write','Grep','Glob','Edit'],
allowedTools: ['Read','Write','Grep','Glob','Edit'],
disallowedTools: ['Bash'],
model: 'claude-sonnet-4-6',
settingSources: ['user'],
},
});
for await (const m of iter) { /* log */ }
Fixture files: 40 markdown files of ~1 KB each, generated deterministically. Any content works — the fixtures exist so Read produces context; the failure is triggered by the subsequent Write.
Timing evidence — failing run
Our apiKeyHelper logged:
1783550720.062 ppid=2055 exp=1783551019 ttl_s=299
1783550780.667 ppid=2110 exp=1783551080 ttl_s=300
Two helper invocations. Second call at t+60 s (relative to first) minted a token with exp = t+360 s. No further helper calls fire until the run terminates 351 s later.
Our SDK message-loop logged (times relative to run start, so first helper call ≈ t+2 s from below):
t+8s turn=1
t+9s turn=3
t+16s turn=4
... (40 short Read turns, one HTTP request each)
t+95s turn=42
t+110s turn=43
t+110s turn=44 ← last Read; the massive Write turn begins immediately after
[ONE HTTP REQUEST, STREAMING BEGINS HERE]
no more helper calls fire — no new request to auth
t+411s turn=45
t+411s RESULT subtype=success is_error=true turns=45
result="API Error: Response stalled mid-stream. The response above may be incomplete."
t+411s THREW: Claude Code returned an error result: API Error: Response stalled mid-stream.
Key data points:
- Streaming request opens ~t+110 s using the token from the t+60 s helper call (exp = t+360 s).
- Token expires mid-stream at t+360 s.
- Stream dies 51 s later at t+411 s — right around the auth layer's next check on the open connection.
- Between t+60 s and t+411 s, no
apiKeyHelper calls fire — a streaming request is one HTTP request, so no cache-boundary crossing, so no helper invocation.
Timing evidence — baseline that works
Same setup, same auth path, same model. Only difference: the prompt asks for many short Read+Write cycles (120 fixtures, one summary each) instead of one massive Write.
Helper log (24-minute run):
1783548646.643 exp=1783548946 ttl_s=300 ← t+0 s
1783548713.742 exp=1783549013 ttl_s=300 ← t+67 s
1783548774.748 exp=1783549074 ttl_s=300 ← t+128 s
... (23 total, all 60–70 s apart)
1783550050.617 exp=1783550350 ttl_s=300 ← t+1404 s (end)
Every helper call is ~60 s after the previous one. CLAUDE_CODE_API_KEY_HELPER_TTL_MS=60000 is honored. No individual streaming request lasts long enough to cross a token boundary. Run completed successfully at 248 turns with RESULT subtype=success is_error=false.
This falsifies "interactive mode holds one token" — the CLI does re-invoke the helper on the ~60 s cadence between turns. The bug is specifically about a single HTTP request whose response streams past its bearer's exp, not about session-level caching.
Two additional occurrences from a different agent workload
Two other production agent runs on the same auth setup, doing complex code-writing tasks. Both hit the "The token is expired" variant on a trailing SDK call after result=success (rather than the "stalled" variant). Full inline transcript from one of them (times UTC):
20:10:45 [agent:implement] start (workspace-write, claude-sonnet-4-6)
20:10:47 [agent:implement] turn 1
20:10:48 [agent:implement] turn 2
20:10:48 [agent:implement] turn 3: Glob
...
20:11:01 [agent:implement] turn 18: Read(pyproject.toml)
20:15:20 [agent:implement] turn 19 ← 4:19 GAP: single-stream turn
20:15:23 [agent:implement] turn 20
20:16:04 [agent:implement] turn 21: Write(app.py)
20:16:06 [agent:implement] turn 22
20:16:06.42 [agent:implement] result=success turns=22 changed=1
20:16:06.51 Error: Claude Code returned an error result: Failed to authenticate.
API Error: 403 {"result":{"allow":false,"error":["The token is expired"],
"github_audience_valid":true,"has_required_claims":true,
"iss_is_external":true,"token_type":"GITHUB"}}
The 4:19 gap between turns 18 and 19 is a single streaming turn that spans the exp boundary. Then the model returns result=success, and 96 ms later a trailing HTTP call from the SDK/CLI fires with the (now-expired) token used during the stream and gets the 403.
Comparison — claude -p "$PROMPT" doesn't hit this
The same auth setup with claude -p "$PROMPT" --output-format stream-json --verbose runs 12+ minutes without failure. The difference is workload shape: review-style workloads produce many short streaming turns (one HTTP request per file read, one per comment posted). Every new request fires apiKeyHelper afresh, so no individual stream ever exceeds the bearer's TTL. It's specifically the single long stream that breaks — the mode (interactive vs one-shot) or the flag (--input-format stream-json vs -p) is not the discriminator.
Requested behavior
Any of these, in order of preference:
- Pre-open TTL check. Before opening a new streaming request, check the currently-cached bearer's remaining TTL. If less than N seconds (configurable? default
60?), invoke apiKeyHelper synchronously first. This would fix ~95 % of cases — a bearer minted moments before opening a long stream would carry its full TTL into it. Simple, no wire-protocol change, no consumer-side integration needed.
- In-stream renewal. When approaching the current bearer's
exp during an open stream, invoke apiKeyHelper and present the new bearer to the server on subsequent frames (or force a reconnect if the protocol requires it). More complex; probably requires server cooperation.
- Consumer-side hook. Expose an SDK option (
onStreamAuthRefresh?) or env var so consumers who know their workload will produce long turns can force a proactive refresh.
What we've ruled out (to save triage time)
- "Session mode holds one token for the whole session." Falsified by our baseline run above —
apiKeyHelper fires 23 times at 60–70 s intervals across 24 min of --input-format stream-json operation.
- "
CLAUDE_CODE_API_KEY_HELPER_TTL_MS isn't honored." It IS honored — cache TTL is respected between requests. The bug is that a single streaming request outlasts any cache TTL setting could bridge.
- "Auth proxy rejecting valid tokens." The token really is past its
exp at the moment of rejection — verified against the JWT claims logged from our helper.
- "Version-specific regression." Observed on the versions in the Environment section; no reason to believe earlier versions differ (
apiKeyHelper has always been per-request).
Attached artifacts
helper.log (2 lines, sanitised — token content stripped, exp values preserved).
run.log (49 lines, sanitised — turn-by-turn timestamps, the terminal RESULT is_error=true line, and the throw).
- Sanitised prompt + Node driver above are the exact reproducer.
Happy to run additional experiments — the reproducer iterates in ~7 minutes.
Summary
When
claude-code(spawned by@anthropic-ai/claude-agent-sdkor invoked directly) opens a single streaming HTTP request whose response streams for longer than the current bearer token's remaining TTL, the auth layer kills the connection when the token expires. There is no mechanism to renew auth on an already-open stream —apiKeyHelperis only consulted between HTTP requests. Consumers using short-lived bearers (e.g. viaapiKeyHelper) hit this reliably on any single turn that generates a large model response.Surfaces two ways depending on when the auth check trips:
Response stalled mid-stream— anis_error: trueresult when the auth layer kills the streaming connection itself.Failed to authenticate. API Error: 403 {"error":["The token is expired"]}— when a follow-up HTTP call afterresult=successreuses a token that expired during the preceding stream.Environment
@anthropic-ai/claude-agent-sdk@0.3.202claudeversion2.1.202(per SDKmanifest.json)claude-sonnet-4-6apiKeyHelperin~/.claude/settings.jsonminting short-lived bearer tokens (~300 s TTL) against a bearer-validating proxy that fronts the Anthropic-compatible API.CLAUDE_CODE_API_KEY_HELPER_TTL_MS=60000is set on the environment;apiKeyHelperDOES fire on the ~60 s cadence between requests (verified — see cadence evidence below).Reproducer
The failure requires two things: (1) an
apiKeyHelperthat returns tokens with short TTL that the auth proxy strictly enforces, and (2) a prompt that forces the model to produce a single long streaming response. We built a minimal repro on GitHub Actions that reliably fires the failure in ~7 min. Sanitised setup:1.
~/.claude/settings.json:{ "apiKeyHelper": "/path/to/helper.sh", "env": { "ANTHROPIC_BASE_URL": "https://<bearer-validating-proxy>/<instance>" } }2.
helper.sh— the specific implementation ofapiKeyHelper. Ours mints a token via a short-lived OIDC endpoint on every invocation and logsexpfrom the decoded JWT so we can correlate helper calls against stream timing:3. Node driver — calls the SDK directly, no other wrappers:
Fixture files: 40 markdown files of ~1 KB each, generated deterministically. Any content works — the fixtures exist so
Readproduces context; the failure is triggered by the subsequent Write.Timing evidence — failing run
Our
apiKeyHelperlogged:Two helper invocations. Second call at
t+60 s(relative to first) minted a token with exp =t+360 s. No further helper calls fire until the run terminates 351 s later.Our SDK message-loop logged (times relative to run start, so first helper call ≈
t+2 sfrom below):Key data points:
apiKeyHelpercalls fire — a streaming request is one HTTP request, so no cache-boundary crossing, so no helper invocation.Timing evidence — baseline that works
Same setup, same auth path, same model. Only difference: the prompt asks for many short Read+Write cycles (120 fixtures, one summary each) instead of one massive Write.
Helper log (24-minute run):
Every helper call is ~60 s after the previous one.
CLAUDE_CODE_API_KEY_HELPER_TTL_MS=60000is honored. No individual streaming request lasts long enough to cross a token boundary. Run completed successfully at 248 turns withRESULT subtype=success is_error=false.This falsifies "interactive mode holds one token" — the CLI does re-invoke the helper on the ~60 s cadence between turns. The bug is specifically about a single HTTP request whose response streams past its bearer's exp, not about session-level caching.
Two additional occurrences from a different agent workload
Two other production agent runs on the same auth setup, doing complex code-writing tasks. Both hit the "The token is expired" variant on a trailing SDK call after
result=success(rather than the "stalled" variant). Full inline transcript from one of them (times UTC):The 4:19 gap between turns 18 and 19 is a single streaming turn that spans the exp boundary. Then the model returns
result=success, and 96 ms later a trailing HTTP call from the SDK/CLI fires with the (now-expired) token used during the stream and gets the 403.Comparison —
claude -p "$PROMPT"doesn't hit thisThe same auth setup with
claude -p "$PROMPT" --output-format stream-json --verboseruns 12+ minutes without failure. The difference is workload shape: review-style workloads produce many short streaming turns (one HTTP request per file read, one per comment posted). Every new request firesapiKeyHelperafresh, so no individual stream ever exceeds the bearer's TTL. It's specifically the single long stream that breaks — the mode (interactive vs one-shot) or the flag (--input-format stream-jsonvs-p) is not the discriminator.Requested behavior
Any of these, in order of preference:
60?), invokeapiKeyHelpersynchronously first. This would fix ~95 % of cases — a bearer minted moments before opening a long stream would carry its full TTL into it. Simple, no wire-protocol change, no consumer-side integration needed.expduring an open stream, invokeapiKeyHelperand present the new bearer to the server on subsequent frames (or force a reconnect if the protocol requires it). More complex; probably requires server cooperation.onStreamAuthRefresh?) or env var so consumers who know their workload will produce long turns can force a proactive refresh.What we've ruled out (to save triage time)
apiKeyHelperfires 23 times at 60–70 s intervals across 24 min of--input-format stream-jsonoperation.CLAUDE_CODE_API_KEY_HELPER_TTL_MSisn't honored." It IS honored — cache TTL is respected between requests. The bug is that a single streaming request outlasts any cache TTL setting could bridge.expat the moment of rejection — verified against the JWT claims logged from our helper.apiKeyHelperhas always been per-request).Attached artifacts
helper.log(2 lines, sanitised — token content stripped, exp values preserved).run.log(49 lines, sanitised — turn-by-turn timestamps, the terminalRESULT is_error=trueline, and the throw).Happy to run additional experiments — the reproducer iterates in ~7 minutes.