401 Unauthorized from an AI API: Incorrect API Key, invalid_api_key or invalid x-api-key — What Actually Broke
A 401 is decided before the model, your balance or rate limits are even considered. One curl call tells you whether the key is bad or simply never arrived intact; the table covers the rest.
1. Where a 401 sits in the request pipeline
Think of an API request as passing a series of gates: identity first (who are you), then permission (may you use this resource), then billing and rate limits, and only then does a model do any work. A 401 stops at the first gate. That single fact rules out a lot of wasted effort: switching models, trimming the prompt or topping up will not make it go away. Anthropic's official error reference spells the distinctions out: 401 is `authentication_error`, meaning there is a problem with the API key — for example it is malformed, revoked or expired; 403 `permission_error` means the key lacks permission for the resource; 402 `billing_error` points at billing or payment details; 429 `rate_limit_error` means a rate limit was hit. Each code tells you which gate stopped the request, so read the status and the error type before you touch anything. For OpenAI's wording and categories, go by its official error-codes page as it reads today (platform.openai.com/docs/guides/error-codes).
2. The first cut: bad key, or key never delivered?
The reflex response to a 401 is to generate another key. When the new key fails too, people start blaming the provider. A faster move is to cut the problem in half: from a terminal, outside your application, send the smallest possible request with the same key to the same base_url. If curl also returns 401, the key is bad or it belongs to a different provider than the endpoint. If curl succeeds and your app still returns 401, the key is fine and the fault is in how your app loads it, in an environment variable, or in settings a client has saved. Make sure the test call genuinely requires authentication: on some endpoints the model list is public and answers without any key, so getting a list back proves nothing about the key.
# OpenAI-compatible: key goes in Authorization: Bearer
curl -sS https://cocodot.co/api/ai/v1/chat/completions \
-H "Authorization: Bearer $MY_KEY" \
-H "Content-Type: application/json" \
-d '{"model":"claude-sonnet-5","messages":[{"role":"user","content":"ping"}],"max_tokens":16}'
# Anthropic-style: x-api-key plus anthropic-version, as in Anthropic's docs
curl -sS https://cocodot.co/api/ai/v1/messages \
-H "x-api-key: $MY_KEY" \
-H "anthropic-version: 2023-06-01" \
-H "content-type: application/json" \
-d '{"model":"claude-sonnet-5","max_tokens":16,"messages":[{"role":"user","content":"ping"}]}'3. "The key is definitely correct" — usually it is not the same string
When someone insists the key is right, the usual truth is that the string they are looking at is not the string their code sends. Three causes come up again and again. The copy lost a few characters at the start or end — and because many consoles show a key only once, a bad copy means creating a new key. An invisible character slipped in: a trailing newline, a space, or quotes picked up when the key travelled through a chat app, a document or a config file. Or the placeholder from sample code, such as `sk-YOUR_KEY`, was never replaced, or was replaced in a different file from the one that actually runs. The check is to log a fingerprint, not the key: its length and boolean checks for leading or trailing whitespace. A length that does not match the console, or whitespace at either end, is your answer. Never paste a full key into logs, tickets or screenshots; if a key has been exposed anywhere public, revoke it rather than hoping nobody noticed.
import os
k = os.environ.get("MY_KEY", "")
print("len =", len(k))
print("outer_whitespace =", k != k.strip())
print("contains_newline =", "\n" in k or "\r" in k)4. The sneakiest 401: key and base_url from different providers
A key is only valid at the provider that issued it. Send provider A's key to provider B's address and B's authentication system has simply never seen that string, so the only possible answer is 401. It is confusing precisely because the key is healthy and still works where it came from. The common versions: you use an official SDK against a third-party endpoint but the base_url setting never took effect, and most SDKs fall back to the vendor's default address when none is given, so the third-party key goes to the vendor; the reverse, where a vendor key is pasted into a gateway that only accepts its own keys; or a tool where you swapped the key but not the address, or the address but not the key. The check takes seconds: log the URL your code actually calls and confirm it belongs to the same provider as the key. On cocodot, for example, OpenAI SDKs and clients use `https://cocodot.co/api/ai/v1`, Anthropic SDKs use `https://cocodot.co/api/ai` with no trailing /v1, and keys are created in the console and start with cocodot-. Mixing up those two addresses typically gives a 404; a key sent to the wrong provider is what gives a 401.
5. Headers: Bearer or x-api-key
Protocols disagree about where the key goes. OpenAI-compatible APIs expect `Authorization: Bearer <key>`. The requests in Anthropic's documentation send `x-api-key: <key>` together with an `anthropic-version` header. If you build HTTP requests by hand, watch for a missing `Bearer ` prefix, a doubled `Bearer Bearer ...` (the SDK already added the prefix and you added another), the key placed in a query string or the JSON body, or a misspelled header name. Official SDKs rarely get the header wrong; hand-rolled requests with requests, fetch or axios are where it breaks. Some compatible endpoints accept either style — cocodot's /v1/messages accepts x-api-key or Bearer — but do not assume every provider does; follow the provider's own docs.
6. Works on my machine, 401 in production
When curl works and the app does not, the app is very likely holding a different value than you think. The usual suspects: an old key exported in your shell while the .env file has the new one — many .env loaders do not overwrite variables that already exist, so the old value silently wins; a changed .env or system variable while the IDE, terminal or dev server was never restarted, so the running process still has the old value; code running in a container, CI job or serverless function where your local variables were never passed in, so the key is an empty string; or two places in the codebase reading the key under different variable names, with only one of them updated. Production adds its own twist — a secret updated in one environment but not another, or a deployment that is still running the previous configuration. The fix is the fingerprint from section 3, logged on the line just before the request is sent, in the environment that fails. That shows you the value that actually goes out.
7. 401 inside Claude Code, Cursor and other clients
In desktop tools and CLIs, a 401 is usually layered configuration: the tool has a key saved in its settings, another sits in an environment variable, a login session holds a third, and the one in effect is not the one you just edited. Find out which address and key the tool is really using (its settings screen or debug output), remove every extra set so only one remains, then quit the tool completely and start it again. For Claude Code pointed at cocodot, the /claude-code page uses two environment variables: `ANTHROPIC_BASE_URL=https://cocodot.co/api/ai`, and `ANTHROPIC_AUTH_TOKEN` set to your cocodot key. If you previously configured another provider's key or address, clear it in the current shell before launching. Full Claude Code setup is at /hub/claude-code; connecting Cursor to a custom endpoint is covered at /hub/cursor-china-claude-gpt-api.
8. Still 401? Rotate the key and bring the request ID
If everything above checks out and the 401 persists, stop fighting that key: revoke it in the console, create a new one, and update every place that uses it — servers, CI secrets and teammates' machines included — then repeat the curl test from section 2. If that still fails, contact the provider's support with the request ID, the time of the request and the exact error body. Anthropic's documentation notes that every API response carries a unique `request-id` header, and error bodies repeat it as a `request_id` field, which lets support find that exact request. Once the 401 is gone, a different error means the request cleared the identity gate and you need a different playbook: wrong model names at /hub/api-model-not-found-fix, rate limits and concurrency at /hub/api-429-rate-limit-concurrency-china, Claude overload at /hub/claude-api-529-overloaded, oversized context at /hub/context-length-exceeded-fix, OpenRouter region blocks at /hub/openrouter-403-china-alternative, and timeouts or connection failures at /hub/ai-api-errors-china-fix.
401 symptom → root cause → fix
| Symptom | Root cause | Fix |
|---|---|---|
| A freshly created key also returns 401 | The key was truncated, or picked up a leading or trailing space, newline or quote | Compare the key's length and first and last characters with the console; copy it again |
| curl works, the app returns 401 | The app loads an old key, an empty string or a placeholder | Log a fingerprint of the key (never the full key) right next to the request |
| 401 started right after switching providers | Provider A's key is being sent to provider B's address | Key and base_url must come from the same provider; log the real request URL |
| Only one SDK or client returns 401 | Wrong header: Bearer where x-api-key is expected, or the reverse | Use the header style the endpoint documents |
| Everything worked, then every call returns 401 | The key was revoked, deleted or expired | Check the key's status in the console, rotate it, update every place it is used |
| The status is 403 or 402, not 401 | You were identified; the problem is permissions or billing | Troubleshoot that status code — a new key will not help |