On a Saturday afternoon I was rereading Anthropic's authentication page at my iMac when one sentence stopped me. A key that isn't scoped to a workspace returns a 400 unless every request carries an extra header.
What I noticed was my own assumption. Somewhere along the way I had settled on "key trouble means 401, and the fix is a new key." A 400 had simply never been on my list.
Below I sort out two ways a valid key can still fail, using what the official Authentication page describes. One caveat up front: the error messages here are quoted from the documentation. I did not reproduce them by changing organization settings in my own account.
Split the symptoms in two first
Start with the HTTP status and the first words of the message. If you reissue the key without reading them, a new key will often return the same error.
| Status | Message starts with | Look at | Is the key valid? |
|---|---|---|---|
| 400 | This API key is not scoped to a workspace … must include the anthropic-workspace-id header | Request headers | Valid |
| 400 | anthropic-workspace-id header must be a valid workspace ID | Header value format | Valid |
| 404 | Workspace … not found | Typo in the ID, or permissions | Valid |
| 401 | CORS requests are not allowed for this Organization | The caller is a browser | Valid |
| 401 | Any other authentication_error | The key itself (expired, deleted, revoked) | Possibly not |
Rows marked "Valid" will not be fixed by a new key. That is the reason to pause before reaching for the Console.
Pattern 1: 400, "not scoped to a workspace"
According to the docs, a key created for a specific workspace works only in that workspace and needs no workspace ID in the request. A key created without a workspace needs the anthropic-workspace-id header on every request. Leave it out and you get the first 400 in the table.
There are two ways out. The first is to add the header. With the Python SDK you can set it once, when the client is created.
from anthropic import Anthropic
# Goal: use a key that is not scoped to a workspace without repeating the header
client = Anthropic(
default_headers={"anthropic-workspace-id": "wrkspc_YOUR_WORKSPACE_ID"},
)
message = client.messages.create(
model="claude-opus-5-5",
max_tokens=256,
messages=[{"role": "user", "content": "Connection check. Reply with one short sentence."}],
)
print(message.content[0].text)I put the header on the client because adding it per call means one forgotten function returns a 400 while the rest work. Then the problem looks like "the key" when it is really "a missed line," and the logs won't tell you the difference.
The second way is to create a new key scoped to the workspace, from the API keys page in the Console. That means replacing the old key everywhere it was handed out. For a single small script, adding the header is faster. For a key you've distributed widely, I'd list where it lives before replacing anything.
You can find the ID in the ID column of the Console's workspace list; the docs show it in a wrkspc_ form. A malformed value gives a 400, and an unknown workspace, or one you can't access, gives a 404. The docs say the 404 text is the same in both cases, so a typo and missing permission can't be told apart from the message alone.
Pattern 2: 401, "CORS requests are not allowed for this Organization"
This is the most confusing one: the key is right, yet you get a 401.
When the caller is JavaScript in a browser, the request carries an Origin header. Per the docs, organizations with zero data retention (ZDR) or the Compliance API enabled reject any request that has that header, even with the direct-browser-access header set. A Console organization linked to an Enterprise parent inherits the setting, so it can apply even when your own Console shows no Compliance API section.
One more detail is easy to miss. If your backend forwards a browser's request as-is, the original Origin header travels with it and you get the same error. The docs ask you to build a new request to the API instead.
In short, for some organizations a browser calling the Claude API directly simply isn't an option. What you change is the architecture, not the key.
# Goal: the browser never sees the key; only your server calls the API
# (Flask is just an example. The point is that the key lives only in a server environment variable.)
import os
from flask import Flask, request, jsonify
from anthropic import Anthropic
app = Flask(__name__)
client = Anthropic() # reads ANTHROPIC_API_KEY from the environment
@app.post("/api/ask")
def ask():
question = request.get_json(force=True).get("question", "")[:2000]
# Don't forward the browser's request; create a fresh API request here
msg = client.messages.create(
model="claude-opus-5-5",
max_tokens=512,
messages=[{"role": "user", "content": question}],
)
return jsonify({"answer": msg.content[0].text})I cap the question at 2,000 characters so that whatever is typed on screen can't turn straight into spend.
As an indie developer running my own web sites, I keep API keys only as server-side secrets, and the page only ever receives the resulting text. I hold that line for a plain reason: anything in a page's code can be read. This 401 is a reminder that some organizations make the same line a rule.
A triage script you can run
Comparing a table every time an error arrives gets tedious, so here is a small function that returns a likely cause from the status and message. It assumes the wording above; if the wording changes, this is the only place to edit.
import json
def triage(status: int, body: str) -> str:
"""Sort a Claude API error response before you recreate the key."""
try:
msg = json.loads(body)["error"]["message"]
except (ValueError, KeyError, TypeError):
msg = body
if status == 400 and "not scoped to a workspace" in msg:
return "Key is valid. The anthropic-workspace-id header is missing"
if status == 400 and "must be a valid workspace ID" in msg:
return "The header value is not in workspace ID format"
if status == 404 and msg.startswith("Workspace `"):
return "Typo in the ID, or no permission for that workspace"
if status == 401 and msg.startswith("CORS requests are not allowed"):
return "Key is valid. Direct browser calls are blocked; move the call to a server"
if status == 401:
return "Suspect the key: expired, deleted, disabled, or revoked"
return "Something else: log the status and message and move on"I ran five sample payloads through it and got the expected answers. Those payloads were copied from the documentation's wording, though, not captured from live responses.
The final branch mentions expiry because the docs say an expired key returns a 401 authentication_error and cannot be reactivated. I've written separately about designing key expiry for unattended jobs.
The order I check things in
- Read the status. For 400 and 404 the key is valid, so I don't reissue it
- For 401, check whether the message starts with
CORS requests are not allowed - If it does, find out whether the caller is a browser or a forwarded browser request
- If it doesn't, check expiry, deletion and disabling on the Console's API keys page
Done this way, reissuing the key becomes the last move rather than the first.
Doubt the key only after reading the status and the message. That is the one order I decided not to break after rereading the page.
A good first step is to pull one 400 and one 401 from your own error logs and see which row of the table each one lands on.