[BUG] security-guidance: model reviews 401 behind an LLM gateway because ANTHROPIC_CUSTOM_HEADERS is never sent
Preflight Checklist
- [x] I have searched existing issues. The closest are #96860 (model reviews skip for subscription sign-ins: no credential reaches the hook) and #81057 (a failed review is reported as clean). This one is different: a credential does reach the gateway, but the gateway key in
ANTHROPIC_CUSTOM_HEADERSnever does. - [x] This is a single bug report.
- [x] I am using the latest version: Claude Code 2.1.291, security-guidance 2.0.10 (the current
versiononmain).
What's Wrong?
Behind an LLM gateway that identifies callers by a custom header, the security-guidance model reviews fail with 401 every time.
The plugin routes its reviews through the gateway on purpose: _anthropic_base_url() reads ANTHROPIC_BASE_URL so that "customers behind an LLM gateway (LiteLLM, Bifrost, self-hosted Anthropic-compatible proxy) can route the plugin's reviews through their gateway". But _build_auth_headers() sends only Content-Type, anthropic-version, anthropic-beta, and either x-api-key or Authorization. Nothing in hooks/llm.py reads ANTHROPIC_CUSTOM_HEADERS.
That breaks LiteLLM's documented setup for Claude subscriptions (Claude Code Max subscription tutorial, BYOK tutorial). There, the subscription sign-in stays in Authorization and is forwarded to Anthropic, and the gateway key travels in ANTHROPIC_CUSTOM_HEADERS as x-litellm-api-key. Claude Code itself sends that header, so normal turns work. The plugin's own call arrives without it, so LiteLLM treats the bearer as a gateway key it doesn't know and refuses it.
The documented alternative doesn't help subscription users. Putting the gateway key in ANTHROPIC_AUTH_TOKEN or ANTHROPIC_API_KEY replaces the subscription sign-in and moves Claude Code to per-token billing (see "Conflicts with an existing login" in Connect Claude Code to an LLM gateway).
Result: in every gateway session, the end-of-turn review silently does nothing. The Opus attempt and the claude-sonnet-4-6 fallback both get the same 401. I haven't checked the commit review, which spawns a claude subprocess that should inherit the custom headers.
What Should Happen?
The plugin's direct HTTP calls send the headers in ANTHROPIC_CUSTOM_HEADERS, the same way Claude Code does, whenever it uses ANTHROPIC_BASE_URL.
A minimal fix in _build_auth_headers(): parse ANTHROPIC_CUSTOM_HEADERS (one Name: Value per line) and merge it in, never overriding the auth header the plugin chose:
for line in os.environ.get("ANTHROPIC_CUSTOM_HEADERS", "").splitlines():
name, sep, value = line.partition(":")
if sep and name.strip() and name.strip().lower() not in {k.lower() for k in headers}:
headers[name.strip()] = value.strip()
The same applies to the /v1/models lookup that picks the review model, which goes to the same endpoint.
Error Messages/Logs
From ~/.claude/security/log.txt (key hash truncated):
[2026-10-06 04:54:48.641] API error: 401 - {"error":{"message":"Authentication Error, Invalid proxy server token passed. Received API Key = sk-...[redacted], Key Hash (Token) =[redacted]
[2026-10-06 04:54:48.665] API error: 401 - {"error":{"message":"Authentication Error, Invalid proxy server token passed. Received API Key = sk-...[redacted], Key Hash (Token) =[redacted]
[2026-10-06 04:54:48.665] Stop hook: API call failed with status 401
On the gateway side, LiteLLM logged 13 such 401s for claude-sonnet-4-6 across two days. Claude Code's own requests in the same sessions succeeded.
Steps to Reproduce
- Run a LiteLLM proxy with
forward_client_headers_to_llm_api: true, and issue a virtual key. - Sign in to Claude Code with a Claude subscription. Set
ANTHROPIC_BASE_URLto the proxy andANTHROPIC_CUSTOM_HEADERS="x-litellm-api-key: Bearer <virtual key>". LeaveANTHROPIC_AUTH_TOKENandANTHROPIC_API_KEYunset. - Enable security-guidance and have Claude edit a file in a git repo.
- At the end of the turn,
~/.claude/security/log.txtshowsAPI error: 401and the review does not run. The proxy logs401 Invalid proxy server tokenfor the plugin's request.
Claude Model
Not model-specific.
Is this a regression?
No, as far as I know.
Claude Code Version
2.1.291 (Claude Code); security-guidance 2.0.10
Platform
Anthropic API (Claude subscription sign-in) through a LiteLLM gateway (v1.104.0)
Operating System
Linux (WSL2)
Additional Information
Found while evaluating LiteLLM as a logging gateway for subscription Claude Code traffic at @clevvi.
Related, but not the same: a LiteLLM-side change, BerriAI/litellm#39004, lets a request authenticate with x-api-key alongside an OAuth bearer. Its author states that requests carrying only the OAuth bearer stay rejected, which is the plugin's case, so the fix belongs here.
---
Drafted by Claude Code (agent) and filed at the reporter's request.
4 Comments
One more reference, for the setup this breaks. Other LLM gateways — Subscriptions and gateways documents it as supported:
So
ANTHROPIC_BASE_URLplus a subscription sign-in is a documented configuration, and in it the gateway can only identify the caller throughANTHROPIC_CUSTOM_HEADERS. Setting a gateway credential variable instead would, per the same section, bill the traffic per token.Added by Claude Code (agent) at the reporter's request.
Thanks @jrdeck for the report and proposed fix. I reproduced the same missing-header behavior in this repository's public source at
79babc372d64101f981bd2b52c3dbe588596dc56and prepared an additive patch plus regression tests below.Scope: this checkout's
security-guidancemanifest says 2.0.0; your report describes marketplace 2.0.10. The shared problem is_build_auth_headers()in the raw HTTP review path. This checkout does not have the marketplace/v1/modelslookup, so this patch/test does not cover that lookup or claim to validate the entire marketplace version.Validation: Python 3.9.6, real
_call_claude()→urllib→ a loopback HTTP gateway. With a requiredx-litellm-api-key, the original code returnsNoneand records HTTP 401 for both API-key and token authentication. With this eight-line production change, both receive and parse the gateway's synthetic success response. Of seven test methods, six fail against the original (seven assertion failures including subtests); all seven pass after the change. The unset/empty-header control passes in both versions. I also applied the complete patch to a clean copy of the pinned source:git apply --checkand all seven tests pass.Coverage also includes API-key-first behavior when both credentials exist; 401 → token fallback and its sticky preference; custom
Authorizationalongside API-key auth; case-insensitive collisions with already generated auth/protocol headers; newline/CRLF separation, whitespace, empty values and values containing colons; duplicate custom names differing in case; and header construction with no base-URL override. Each client subprocess receives only fake credentials and isolated environment/HOME. No SDK was installed and no model or external gateway was called.Merge semantics: following your additive suggestion, headers already generated for the current request win case-insensitively, and the first custom occurrence wins. Other header names remain allowed, including
Authorizationwhen the selected auth isx-api-key; the fallback still regenerates and preserves its chosen auth. This collision policy is a proposed plugin design choice, not a claim about the CLI's collision precedence. The environment-variable reference and gateway guide describe newline-separated additional headers without a custom-endpoint-only restriction, so the merge applies whenever the variable is set. The patch adds no header-value logging. Invalid HTTP characters are left tourllibvalidation; it does not reproduce the CLI's configuration diagnostics.Run from the repository root:
External PR creation is currently restricted. Would a maintainer be willing to take this patch, or indicate the preferred submission route? Credit for identifying the issue and the original fix belongs to @jrdeck; this follow-up contributes the public-repository reproduction and tests. I have not opened a PR or pushed a branch.
<details>
<summary>Complete patch against 79babc372d64101f981bd2b52c3dbe588596dc56</summary>
</details>
Hi, I'm picking this up. I'll make
_build_auth_headers()mergeANTHROPIC_CUSTOM_HEADERS(oneName: Valueper line, never overriding the auth/protocol headers the plugin sets) so gateway calls carry the same headers Claude Code sends. — Sahil JagtapFix is up: https://github.com/sahiljagtap08/claude-code/pull/2 (branch
sahiljagtap/security-guidance-custom-headers, one commit on currentmain). PRs from forks are currently rejected on this repo, so it's on my fork for a maintainer to cherry-pick:_build_auth_headers()now mergesANTHROPIC_CUSTOM_HEADERS(first colon splits name/value,\nor\r\nseparated). Headers the plugin already set for the request win case-insensitively, so a customAuthorization:line can't replace the credential the 401 fallback chose. Verified against a loopback gateway that 401s withoutx-litellm-api-key: request succeeds with the variable set, still 401s without it. — Sahil Jagtap