403 Forbidden — the key is fine, and that is exactly the problem
- Don't rotate the key — 403 means identity already passed; a new key on the same account carries the same permissions, so it cannot help.
- Hold the credential, vary the model: retry with a basic model. Basic 200 + target 403 = entitlement. Two requests, thirty seconds.
- Every model 403s and it tracks your location = region block. Intermittent and request-specific = endpoint policy.
A 403 means the server read your credential, accepted it, worked out who you are, and then declined the request anyway. Identity already succeeded. So every fix aimed at identity — regenerating the key, re-pasting it, switching header styles, reinstalling the client — is guaranteed to change nothing. 403 is not asking who are you. It is answering you may not do this, and the answer to that does not live in your key.
Reaching for a new key on a 403 is the single most common and most expensive mistake in this family of errors. It costs an hour and it cannot possibly work: a new key issued to the same account carries the same permissions as the old one. Rotating is not a fix, it is re-running the same experiment.
What you're seeing
API Error: 403 {"type":"error","error":{"type":"permission_error","message":"You do not have permission to use this model"}}
403 Forbidden
{"error":{"message":"You do not have permission to access this resource","type":"permission_error","code":"403"}}
<html><head><title>403 Forbidden</title></head>
The fourth one matters more than it looks. An HTML 403 with no JSON body means the API never saw your request — something in front of it refused you at the edge. A JSON body with "type":"permission_error" means the API did see it, understood it, and said no. Those are different problems with different owners.
401 or 403 — the distinction that decides everything
These two get treated as one problem constantly, and the correct responses are close to opposite.
| 401 Unauthorized | 403 Forbidden | |
|---|---|---|
| What the server decided | it does not know who you are | it knows exactly who you are |
| Your credential | rejected, missing, or malformed | accepted |
| Does a new key help? | yes, if the old one was genuinely bad | no — same account, same permissions |
| What changes the outcome | the credential | the model, region, feature, or account state you asked for |
| Where the fix lives | your configuration | the account, or the operator's policy |
| Right page | 401 Unauthorized | this one |
If you are not certain which one you have, read the body rather than the status line — step 1 below. Endpoints do not always agree with themselves.
The three sources of a 403
They look almost identical in the response and they behave completely differently. The fingerprint column is how you tell them apart without asking anyone.
| Where it comes from | Typical message | Fingerprint | Who can fix it | |
|---|---|---|---|---|
| ① Entitlement | the account lacks that model or that feature | names a specific model or feature | model-specific — a more basic model succeeds with the same credential | the account owner, or the operator |
| ② Region or compliance | where the request comes from | generic, sometimes mentions region or country | location-specific — usually fails for every model, identically, every time | the operator; nothing in your config touches it |
| ③ Endpoint policy | concurrency limits, content policy, account state | generic; often HTML with no JSON body | request- or time-specific — a different prompt or a later attempt succeeds | you, by changing the request — or the operator |
Note what the fingerprints have in common: none of them involve the key. All three are distinguished by varying something other than the credential and watching what changes.
Check in this order
1 · Confirm it is really a 403, and read the body rather than the status line#
# <model-id>: copy an id from https://api.9coding.com/v1/models
curl -sS -o /tmp/403body.txt -w 'status=%{http_code}\n' https://api.9coding.com/v1/messages \
-H "Authorization: Bearer $ANTHROPIC_AUTH_TOKEN" \
-H "content-type: application/json" \
-H "anthropic-version: 2023-06-01" \
-d '{"model":"<model-id>","max_tokens":16,"messages":[{"role":"user","content":"hi"}]}'
cat /tmp/403body.txt
Read the type field, not the number:
"type":"permission_error"→ a genuine 403. Stay here."type":"authentication_error"→ the status code and the body disagree. Trust the body. This is a credential problem in a 403's coat. Go to 401 Unauthorized.- No JSON body at all — HTML, or empty → you were refused before the API layer. Source ③, edge side. Nothing about your model or your key is involved.
- No status code came back at all → this was never a 403. See connection errors.
2 · The one test that separates ① from ② and ③ — change the model, keep the credential#
This is the whole technique. Hold the credential constant, vary the thing 403 is actually about, and let the two results tell you which source you have.
# <model-id>: copy an id from https://api.9coding.com/v1/models
for M in "<model-id>" "<a more basic model available on the same endpoint>"; do
printf '%s -> ' "$M"
curl -sS -o /dev/null -w '%{http_code}\n' https://api.9coding.com/v1/messages \
-H "Authorization: Bearer $ANTHROPIC_AUTH_TOKEN" \
-H "content-type: application/json" \
-H "anthropic-version: 2023-06-01" \
-d "{\"model\":\"$M\",\"max_tokens\":16,\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}]}"
done
| Basic model | Your model | What you have learned |
|---|---|---|
| 200 | 403 | ① Entitlement. Your credential is valid, your account is active, your network is fine. You simply do not have that model. No configuration change on your machine can grant it |
| 403 | 403 | Not model-specific → ② or ③. Continue to step 3 |
| 401 | 401 | Wrong page. Go to 401 Unauthorized |
| 200 | 404 / not_found_error | Some endpoints report a model you may not use as not found rather than forbidden, so it does not disclose what exists. See Model not available. (Which of the two shapes you get varies by endpoint — check it against yours.) |
The top row is the outcome most people never reach, because they stopped at "403, must be the key" and rotated instead. Two requests, thirty seconds, and you know whether to touch your config at all.
3 · Is it the request rather than the account? (③)#
Keep the model. Strip the request down to nothing.
# <model-id>: copy an id from https://api.9coding.com/v1/models
curl -sS -o /dev/null -w '%{http_code}\n' https://api.9coding.com/v1/messages \
-H "Authorization: Bearer $ANTHROPIC_AUTH_TOKEN" \
-H "content-type: application/json" \
-H "anthropic-version: 2023-06-01" \
-d '{"model":"<model-id>","max_tokens":16,"messages":[{"role":"user","content":"hi"}]}'
- A bare
hisucceeds, your real request 403s → the account is fine and something in the request tripped a policy. Bisect the payload: remove the system prompt, then tools, then attachments, then any unusual parameter — one at a time, re-running after each. The step that flips 403 back to 200 is your answer. - The bare request 403s too → nothing about the content matters. Go to step 4.
4 · Is it where you are, rather than who you are? (②)#
Run the same credential, same model, from a second network location.
- Works elsewhere, fails here → ② Region or compliance restriction.
- Region blocks have a distinctive shape: they hit every model equally, they are immediately reproducible, and they do not respond to anything in your configuration. Compare that with ①, which is selective, and ③, which is intermittent.
- Judge them by that behaviour, not by the response body. A location refusal can be returned by the API as JSON, or applied at an edge layer that answers with HTML or with nothing at all — the shape varies and is not a reliable discriminator. What is reliable is that it fails for everything, everywhere on this network, every time.
There is nothing to fix on your machine for ②, and whether a given restriction may be routed around is a terms-of-service question rather than a technical one. Read the operator's terms before you spend time on it.
5 · Check which account the credential actually belongs to#
Permission attaches to an account, an organisation, or a project scope — not to the string in your environment. Two keys that look interchangeable can carry entirely different entitlements.
env | grep -i anthropic
Check every hit, not just the one you remember setting, and check project- or workspace-scoped configuration files as well. A common version of this: a key that works in one repository fails in another, because the second repository has its own configuration file pointing at a different account. The key is not the variable. The account behind it is.
When it isn't your problem
You are looking at the account or the operator's policy — with nothing to fix on your machine — when:
- it worked earlier today, you changed no configuration, and now every model returns 403,
- other clients on the same account fail identically,
- the response body is HTML rather than JSON, meaning the API never received the request,
- or the failure tracks load rather than tracking what you asked for.
Check the operator's status page, changelog, and your account or billing state — in that order. An endpoint that publishes neither status page nor changelog is one you cannot debug from outside, which is worth knowing before you depend on it. Ours is at status.
When reporting it, include the request id — 9Coding error responses carry one in the form (request id: 2026...). That id plus the timestamp, the model name and the full error text lets the exact call be traced. A report that only says it doesn't work cannot be investigated.
What not to do
Everything in this list is aimed at identity, and identity already passed:
- Don't regenerate the key. Same account, same permissions, same 403.
- Don't switch
ANTHROPIC_API_KEY↔ANTHROPIC_AUTH_TOKEN. That is a 401-shaped fix; if it changes anything, you had a 401. With 9Coding, setANTHROPIC_AUTH_TOKEN— the key begins withsk-9c-, andANTHROPIC_BASE_URLis exactlyhttps://api.9coding.com, no trailing slash and no/v1. - Don't retry in a loop. 403 is a decision, not a queue. Retrying is correct for 429 and 529, and pointless here — except in the specific case of ③, where a paced retry after a pause is a diagnostic, not a fix.
- Don't reinstall the client. The server made this decision; the client just printed it.
Related
- 401 Unauthorized — two layers of authentication, one error message
- Model not available — when the answer comes back 404 instead of 403
- 429 Too Many Requests and 529 Overloaded — limits that do clear on their own
- Connection errors — when you never got a status code at all
- All Claude Code errors — the quick reference table