I have two small tools in this repo that call Claude programmatically: a commit-message generator and a profile-update script. Both started life the same way — grab ANTHROPIC_API_KEY from the environment, fire off a raw HTTPS request to the Anthropic API, parse the JSON back. Textbook. Also broken for me, specifically, and it took a confusing round of "but the token is right there" before I understood why.
The setup that looks fine and isn't
import os, urllib.request, json
def ask_claude(prompt):
req = urllib.request.Request(
"https://api.anthropic.com/v1/messages",
headers={
"x-api-key": os.environ["ANTHROPIC_API_KEY"],
"anthropic-version": "2023-06-01",
"content-type": "application/json",
},
data=json.dumps({
"model": "claude-sonnet-5",
"max_tokens": 512,
"messages": [{"role": "user", "content": prompt}],
}).encode(),
)
return json.load(urllib.request.urlopen(req))
This is correct code. It'll work fine for anyone who provisioned a real ANTHROPIC_API_KEY from the console and exported it. It failed for me with a 401 every single time, and the maddening part is that os.environ["ANTHROPIC_API_KEY"] didn't even raise a KeyError — I'd set something under that name at some point testing, an empty string or a stale value, and the request went out with a technically-present, functionally-invalid credential. Same failure mode as no key at all: 401, "invalid x-api-key."
The actual root cause: two different auth systems
I don't have an Anthropic API key. I use Claude Code under a subscription, authenticated via OAuth through the claude CLI. That's a completely separate credential system from the raw x-api-key header the Messages API expects. There is no ANTHROPIC_API_KEY in my environment that's supposed to work — and a script that assumes there's a fallback env var quietly papers over the fact that it's checking the wrong door for the wrong key.
The fix wasn't "fix the API call." It was "stop making the API call directly" and instead go through the CLI that already holds a valid OAuth session:
import subprocess
def ask_claude(prompt):
return subprocess.check_output(
["claude", "-p", prompt],
text=True,
)
claude -p runs a one-shot prompt through the same authenticated session my interactive terminal uses. No API key, no header, no token to expire out from under a headless script — it rides on whatever session the CLI already trusts.
Why this matters more in headless / CI contexts
Interactively, this class of bug announces itself immediately — you run the command, it 401s, you go check your key. Headless is where it gets expensive: a cron job, a pre-commit hook, a CI step that calls out to an agent doesn't have a human watching the first failure. It fails silently or gets buried in a log nobody reads until three days later when someone asks why the auto-generated commit messages stopped showing up.
The generalizable failure here isn't really about Anthropic specifically — it's "assumed a single auth mechanism when the actual environment has two, and picked the wrong one by default." I've seen the same shape with:
- A tool that only checks for a personal access token, when the actual running context authenticates via a short-lived OIDC token instead.
- A script that reads
AWS_ACCESS_KEY_IDdirectly when the real credential path is an instance role or SSO session, and the env var check happens to find a leftover value from someone's local testing.
The tell in all of these is the same: the token is present and well-formed enough to pass a basic check, so nothing catches it before the request goes out, and the failure surfaces as an auth error that looks like "bad credential" instead of "wrong credential mechanism entirely."
What I check now before wiring up any headless call
- Ask "how does this environment actually authenticate right now, interactively?" before writing the integration — not "what's the documented way to authenticate to this API." They're sometimes different, and subscription/OAuth-based tool access is exactly the case where they diverge.
- Don't trust
os.environ.get(...)truthiness as proof a credential is valid. A present-but-stale value fails identically to a missing one, and the error message won't tell you which. - When a headless script needs to act as "me," prefer shelling out to the CLI I already use interactively over reimplementing its auth. The CLI's maintainers already solved token refresh, session handling, and expiry — a fresh
urllibcall reimplements all of that from a worse starting position. - If a 401 shows up in a script that "should" have a valid key, verify the credential path before touching the request code. The bug is rarely in the HTTP call.
Top comments (0)