◉CLAUDE LABJP
●THREADS — Message threads arrived in beta on the Messages API (11/2): the server keeps the conversation and you send only new messages●CONSOLE — Console IP allowlist is in beta (11/5): requests from addresses not on the list are refused●SONNET 4.5 — Retires on the Claude API on 11/30, 24 days left. Migrate to Sonnet 5.5●WINDOWS — Reports say hooks break silently only on Japanese-locale Windows; check the character encoding first●FAST — Opus 4.8 fast mode is deprecated and will be removed on 11/30. Migrate to Opus 5.5●NEW — I Put a Ten-Line Promise File at the To…●THREADS — Message threads arrived in beta on the Messages API (11/2): the server keeps the conversation and you send only new messages●CONSOLE — Console IP allowlist is in beta (11/5): requests from addresses not on the list are refused●SONNET 4.5 — Retires on the Claude API on 11/30, 24 days left. Migrate to Sonnet 5.5●WINDOWS — Reports say hooks break silently only on Japanese-locale Windows; check the character encoding first●FAST — Opus 4.8 fast mode is deprecated and will be removed on 11/30. Migrate to Opus 5.5●NEW — I Put a Ten-Line Promise File at the To…
Articles/API & SDK
⬡ API & SDK/2026-11-07Intermediate

Your Key Is Valid but You Get a 400 or 401 — Check the Workspace Header and Browser Calls Before Reissuing

When a Claude API key is valid but requests still fail with 400 or 401, two causes are worth ruling out before you create a new key: a missing workspace ID header and a direct call from the browser. A triage table and a small script you can run are included.

Claude API144API keystroubleshooting105workspacesCORS

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.

StatusMessage starts withLook atIs the key valid?
400This API key is not scoped to a workspace … must include the anthropic-workspace-id headerRequest headersValid
400anthropic-workspace-id header must be a valid workspace IDHeader value formatValid
404Workspace … not foundTypo in the ID, or permissionsValid
401CORS requests are not allowed for this OrganizationThe caller is a browserValid
401Any other authentication_errorThe 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

  1. Read the status. For 400 and 404 the key is valid, so I don't reissue it
  2. For 401, check whether the message starts with CORS requests are not allowed
  3. If it does, find out whether the caller is a browser or a forwarded browser request
  4. 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.

Share

Thank You for Reading

Claude Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.

  • ✦Copy-paste ready implementation code
  • ✦New advanced guides published daily
  • ✦$5/mo or $10 for lifetime access
View Membership →

If you found this article helpful, a small tip ($1.50) would mean a lot to us. Your support helps keep this site ad-free and covers server and hosting costs.

Related Articles

⬡ API & SDK2026-10-24
Before You Trust "Credit balance is too low": Three Balances That Don't Share a Wallet
Your Max or Team API credits should still be there, yet the API says the balance is too low. Included credits, purchased credits and the monthly spend cap behave as separate things. Here is the order I check them in, plus a small wrapper for unattended scripts.
⬡ API & SDK2026-10-22
You Hit Your Limit and Got a 400 — Telling Apart Your Own Spend Limit, the Tier Cap, and Rate Limits
Claude API limit errors come in three kinds, and they differ in status code and in whether waiting helps. A limit you set returns 400, the tier cap returns 429, and so do ordinary rate limits. Code and a decision table.
⬡ API & SDK2026-09-02
The 400 that rejects a thinking block, and finding the edit that caused it
On Claude Fable 5.1, editing the system prompt, tools, or earlier messages before a thinking block makes the request fail with a 400. Here is how to check whether your own agent loop does that in a single request, and how to move a rebuilt system prompt and client-side trimming to patterns that leave the prefix untouched.