A 401 says a credential was rejected. It does not say the key string is wrong, and a 403 does not have to mean missing permissions. Both providers return each code in cases where the credential is perfectly valid. One exception is worth checking before anything else: if the rejection echoes back a masked key whose first and last characters don't match the key you hold, the credential reaching the server isn't the one you meant to send.
Three 401s That Look Identical
Every case below returns HTTP 401 with an authentication error type, but the causes need opposite fixes. The messages are what these two endpoints returned to six requests I sent with deliberately invalid keys; a bad credential is refused before authorization runs, so no real key is needed to repeat them.
| What actually happened | Anthropic /v1/messages | OpenAI /v1/responses |
|---|---|---|
| Key arrived and was rejected | API key is invalid. | Incorrect API key provided: sk-proj-**********-key. with "code": "invalid_api_key" |
| No credential arrived at all | x-api-key header is required | Missing bearer or basic authentication in header |
| Credential arrived on the wrong header | Invalid bearer token | Missing bearer or basic authentication in header, identical to the previous row |
Anthropic names all three. The OpenAI endpoint returns one message for both "you sent nothing" and "you sent it on a header I don't read," which is why a relay user can stare at a missing-header error while the key is demonstrably set in the shell.
The response headers do not resolve that ambiguity, but they do separate a rejected credential from one that never reached authorization. Run the six calls and read the headers rather than the prose:
show(){ shift; curl -sS -D - -o /dev/stdout "$@" \
| grep -iE "^HTTP|www-authenticate|x-openai-authorization-error|request-id|x-should-retry|message"; }
A=(-X POST https://api.anthropic.com/v1/messages -H "anthropic-version: 2023-06-01"
-H "content-type: application/json"
-d '{"model":"claude-sonnet-4-5","max_tokens":8,"messages":[{"role":"user","content":"hi"}]}')
O=(-X POST https://api.openai.com/v1/responses -H "content-type: application/json"
-d '{"model":"gpt-5.6","max_output_tokens":16,"input":"hi"}')
show a1 "${A[@]}" -H "x-api-key: sk-ant-api03-not-a-real-key" # rejected
show a2 "${A[@]}" # never arrived
show a3 "${A[@]}" -H "Authorization: Bearer sk-ant-api03-not-a-real-key" # wrong header
show o1 "${O[@]}" -H "Authorization: Bearer sk-proj-not-a-real-key" # rejected
show o2 "${O[@]}" # never arrived
show o3 "${O[@]}" -H "x-api-key: sk-proj-not-a-real-key" # wrong header
These were the discriminating lines on 2026-07-31, trimmed to the fields that differ:
o1 rejected HTTP/2 401 x-openai-authorization-error: 401 "code": "invalid_api_key"
o2 never arrived HTTP/2 401 www-authenticate: Bearer realm="OpenAI API"
o3 wrong header HTTP/2 401 www-authenticate: Bearer realm="OpenAI API"
a1 rejected HTTP/2 401 "request_id": null (no request-id, no x-should-retry)
a2 never arrived HTTP/2 401 request-id: req_011CdZnC9j… x-should-retry: false
a3 wrong header HTTP/2 401 request-id: req_011CdZnCDN… x-should-retry: false
On both endpoints a rejected credential carried the vendor's own authorization field and dropped the challenge header, while one that never reached authorization did the reverse. Repeat calls gave the same split, but this is a dated observation on two endpoints, not documented behaviour: re-run the block above rather than assuming it holds for your provider. Landing in the rejected row puts you on the OpenAI or Claude key page with four things to check: leading or trailing whitespace in the value, a key that was deleted or revoked, a key issued to a different project or organization than the one you are calling, and a stale copy still cached by the client. Regenerate only after the first three come back clean.
Which Credential Your CLI Actually Sent
When a coding CLI reports an invalid key you did not configure, the credential was not cached. It was outranked. Claude Code resolves six sources in a fixed order, and that documented precedence also fixes which header each one travels on.
| Priority | Source | Header sent |
|---|---|---|
| 1 | Cloud provider credentials (CLAUDE_CODE_USE_BEDROCK, _VERTEX, _FOUNDRY) | provider-specific |
| 2 | ANTHROPIC_AUTH_TOKEN | Authorization: Bearer |
| 3 | ANTHROPIC_API_KEY | X-Api-Key |
| 4 | apiKeyHelper script output | as returned |
| 5 | CLAUDE_CODE_OAUTH_TOKEN | OAuth |
| 6 | Subscription login from /login | OAuth |
An approved ANTHROPIC_API_KEY sits above your subscription login, so /login does not displace it; with the -p flag the key is always used when present. Anthropic's own fix list is env | grep ANTHROPIC in the launching shell, then /status, then unset the variable if you meant to use the subscription. The env check only covers rows 2 and 3 of the table; /status is what reports a cloud provider, a helper script or a login as the resolved source.
The credential you swear you never set
One report shows the shape it takes:
"Claude is stuck in API billing mode even though there is no API env variable set and I have linked claude code to my pro max account successfully"
A maintainer's first question was whether an apiKeyHelper was configured. It was, and its entire body printed PLACEHOLDER_NOT_IMPLEMENTED_ON_MAC_YET. A helper that returns garbage, exits non-zero, or prints nothing sends a placeholder credential, and the API rejects it with a 401 that describes the key rather than the script. Claude Code now reports that failure by name within three attempts; the same entry records that before v2.1.208 helper failures surfaced as a generic 401 after about ten silent retries.
Environment variables also get set without your involvement: Anthropic lists direnv, dotenv shell plugins and IDE terminals as sources that load a stale key from a project .env file. The helper is also re-invoked after five minutes or on an HTTP 401, so a broken one reappears on a timer.
When the base URL points at a gateway
If ANTHROPIC_BASE_URL points at an LLM gateway, the text after 401 is the gateway's message, not Anthropic's, and /login cannot change it. The same applies in reverse to OpenAI-compatible clients pointed at a relay. From a Codex report whose error string ends in url: https://openrouter.ai/api/v1/responses:
"I tried clearing all the env variables related to api keys , tried deleting the auth file too didn't work relogged in many times error persists"
Clearing local credentials cannot fix that: the base URL decides who judges the request. Match the credential to what sits at that endpoint. Claude Code documents ANTHROPIC_AUTH_TOKEN for gateways that authenticate with bearer tokens; put the same value in ANTHROPIC_API_KEY and it goes out as X-Api-Key instead, which is row three of the table above. Other clients follow their own contract; check which header yours sends. Claude Code on the Web is the exception: it always uses subscription credentials, and setting either variable in the sandbox does not override them.
403 Cases Where the Key Is Fine
None of this is reproduced first-hand, since each case needs a specific account state or geography; it follows the provider documentation, which is unambiguous that a valid key can produce a 403.
OpenAI's error reference lists 403 - Country, region, or territory not supported — a geography check, with the key untouched. Two rows above it sits the mirror image, 401 - IP not authorized, raised when the request IP is outside the project or organization allowlist. A valid key, an invalid caller.
For API Error: 403 {"error":{"type":"forbidden","message":"Request not allowed"}} after a successful login, Anthropic gives three causes and none of them is a mistyped key: an inactive Pro or Max subscription, a Console account missing the "Claude Code" or "Developer" role, or a corporate proxy interfering with the request. On the platform API, 403 - permission_error means the key lacks permission for that resource, checked against organization and workspace settings, and on the compliance endpoints a valid key with the wrong scopes returns 403 rather than 401 by design.
FAQ
What does error code 403 mean on the Claude API?
Two different things. permission_error means your key has no permission for that resource, which is an organization or workspace setting. Request not allowed after login points instead at subscription status, a missing Console role, or a proxy.
Is a 401 ever worth retrying?
Not on its own. A rejected or absent credential returns the same result next attempt, unlike a 429, where respecting Retry-After before backing off is the fix. The one 401 that resolves itself is the apiKeyHelper case, which Claude Code already retries twice more before reporting.