Troubleshooting · Permissions

403 Forbidden — the key is fine, and that is exactly the problem

30-SECOND VERSION
  1. 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.
  2. Hold the credential, vary the model: retry with a basic model. Basic 200 + target 403 = entitlement. Two requests, thirty seconds.
  3. 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

any of these
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>

Couldn't record Design agent access for your Claude Design projects — Request failed with status code 403.
Try again, or run /design-login to authorize Claude Design for this account.

The last one is a different animal entirely and the steps below do not apply to it — it is about a missing OAuth scope, not about a model or a key. Jump to step 6.

The HTML 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 Unauthorized403 Forbidden
What the server decidedit does not know who you areit knows exactly who you are
Your credentialrejected, missing, or malformedaccepted
Does a new key help?yes, if the old one was genuinely badno — same account, same permissions
What changes the outcomethe credentialthe model, region, feature, or account state you asked for
Where the fix livesyour configurationthe account, or the operator's policy
Right page401 Unauthorizedthis 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 fromTypical messageFingerprintWho can fix it
① Entitlementthe account lacks that model or that featurenames a specific model or featuremodel-specific — a more basic model succeeds with the same credentialthe account owner, or the operator
② Region or compliancewhere the request comes fromgeneric, sometimes mentions region or countrylocation-specific — usually fails for every model, identically, every timethe operator; nothing in your config touches it
③ Endpoint policyconcurrency limits, content policy, account stategeneric; often HTML with no JSON bodyrequest- or time-specific — a different prompt or a later attempt succeedsyou, 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#

terminal
# <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.

terminal
# <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 modelYour modelWhat you have learned
200403① 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
403403Not model-specific → ② or ③. Continue to step 3
401401Wrong page. Go to 401 Unauthorized
200404 / not_found_errorSome 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.

terminal
# <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 hi succeeds, 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.
One timing tell, and it is a reliable one: entitlement does not flicker. A 403 that comes and goes with load, or clears after a pause, is ③ — concurrency or abuse control — not ①. If it is intermittent, stop looking at permissions.

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.

terminal
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.

6 · The 403 that is not about models at all — Couldn't record Design agent access#

either of these
Couldn't record Design agent access for your Claude Design projects — Request failed with status code 403.
Try again, or run /design-login to authorize Claude Design for this account.

FIRST_PARTY_AUTH_REJECTED: api.anthropic.com rejected your claude.ai login for Claude Design (HTTP 403)

If that is the text you got, stop running the steps above — none of them apply. This 403 has nothing to do with a model, a region, an endpoint policy, or an API key. It is a fourth source: the token you are already signed in with is missing a scope. The principle is the same one the rest of this page is built on — identity passed, permission did not — but here the permission lives in the OAuth grant rather than in the account's model entitlements.

An ordinary /login produces a claude.ai token carrying user:profile, user:inference, user:file_upload, user:mcp_servers and user:sessions:claude_code. Claude Design's server replies with a WWW-Authenticate header demanding user:design:read and user:design:write. The ordinary login flow never asks for those two, so the refusal is deterministic: same account, same entitlement, absent scope. Nothing intermittent about it, and nothing to retry.

The command the error names may not run in the place you read the error. /design-login is an interactive command; inside the editor extension it answers /design-login isn't available in this environment. — which is where most people conclude they are stuck. What is reported to work: run /design-login once in a standalone terminal on an up-to-date CLI. The design scopes are then written into the shared credentials file, and the editor extension inherits them on its next start.

A second failure wearing the same status code is a dead end, and it is worth recognising before you spend an afternoon on it: if you added the first-party claude-design HTTP MCP server to your own config (https://api.anthropic.com/v1/design/mcp), its transport attaches the plain login token, so it refuses whatever you do in the UI — and claude mcp login claude-design dead-ends on a decommissioned authorize endpoint rather than granting anything. There is no client-side fix for that one.

Worth being explicit about, since it is the reason this section sits on this page at all: this 403 is issued against your Anthropic account, and your API endpoint does not participate in it. Changing base URL, key, or provider moves it in neither direction — which also means it is not evidence of anything wrong with the endpoint you are on.

Last verified 2026-09-12 against Claude Code 2.1.259–2.1.261, from the public reports #90686 and #92215, both open on that date. Client behaviour changes between releases — re-check before assuming this still holds.

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, set ANTHROPIC_AUTH_TOKEN — the key begins with sk-9c-, and ANTHROPIC_BASE_URL is exactly https://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