ByteScope

403

How to fix HTTP 403 Forbidden

4xx client errorIETF standardRFC 9110

The server understood the request perfectly well and is refusing to carry it out — and unlike a 401, it owes you no header explaining what would have worked.

Why you are seeing 403, and what to do about it

The same three digits are three different problems depending on which side of the request you are on. Read the block that describes you.

Reproduce 403 with curl

Run this against the URL that failed. It prints the status without the error page, so you can see what the server said rather than what the browser rendered.

curl
curl -sS -D - -w '\n%{http_code}\n' https://example.com/admin/

The body is deliberately not discarded here, because on a 403 it is often the only place the reason appears — §15.5.4 requires no explanatory header, so the page itself is the evidence. Read three things. The `server` header and any `cf-ray` say who wrote the refusal: your own application, your proxy, or an edge network answering before either. The body says which kind of refusal it is — your API's JSON error, an nginx default page, or a branded block page carrying a rule or ray id. And the absence of `www-authenticate` is itself informative: if that header is present, you are looking at a 401's problem and credentials may fix it, and if it is missing, adding credentials to this request will not help. To find out whether the edge or the origin refused, run the same command again with `--resolve example.com:443:203.0.113.10`, which makes curl connect to the origin address you name while still sending the original host name and SNI. A 403 that disappears against the origin is an edge or WAF rule; one that survives is your own server. Add `-H 'user-agent: Mozilla/5.0'` if the refusal only reproduces from a browser, since some rules key on that header alone.

What 403 actually means

RFC 9110 §15.5.4 defines 403 as the server understanding the request but refusing to fulfil it, and adds the clause that decides how you debug it: if authentication credentials were provided, the server considers them insufficient, and the client SHOULD NOT automatically repeat the request with the same credentials. That is a direct instruction not to retry, and it is the reason a 403 in a retry loop is always a bug rather than a workaround. The same section says a server that wishes to make public why the request was forbidden can describe the reason in the response content — note “can”, not “must”. There is no mandatory header on a 403 at all, which is exactly what makes it harder to diagnose than a 401: nothing in the response is required to name the rule that fired, so the answer lives in a log you have to go and read. §15.5.4 closes with the escape hatch that produces most of the confusion in this area: the server MAY send 404 instead if it does not wish to reveal that the resource exists, which means a 404 on something you know is there and a 403 on something you know is there are the same decision made by administrators with different taste. Two operational facts follow. First, 403 is not in the list of heuristically cacheable status codes in RFC 9110 §15.1, so an intermediary will not store one without explicit cache headers — a 403 that persists after you fixed the permission is a caching policy somebody wrote, not the default. Second, 403 is method-agnostic and scheme-agnostic: it says nothing about whether you authenticated, which is why “add credentials and retry” is the wrong instinct here and the right one on a 401.

Codes 403 gets mistaken for

Confused withHow to tell them apart
401401 is “I do not know who you are” and 403 is “I know, and no”. Read the headers rather than the words: RFC 9110 requires `WWW-Authenticate` on every 401 because different credentials might succeed, and requires nothing on a 403 because they would not. That is also why retry behaviour differs — a client is expected to re-attempt a 401 with new credentials, while §15.5.4 tells it not to repeat a 403 with the same ones.
404These two are frequently the same decision. §15.5.4 explicitly allows a server that does not wish to reveal that a forbidden resource exists to answer 404 instead, and AWS documents S3 doing exactly that for callers without `s3:ListBucket`. So a 404 that becomes a 200 once you authenticate was never a routing problem, and a 403 on a path you cannot otherwise confirm exists may be the more honest of the two answers.
429A rate limiter does not have to answer 429. Cloudflare's rate limiting rules let the configured action be a block rather than a 429, and GitHub documents that exceeding its rate limits can return either 403 or 429. So a 403 that appears only under load, and clears on its own after a wait, is a limit rather than a permission — check whether it correlates with request volume before you go auditing roles.
451451 (RFC 7725, Unavailable For Legal Reasons) is the narrower code for a refusal made under legal demand rather than by the operator's own policy, and it asks the response to carry a `Link` header with `rel="blocked-by"` identifying the entity making the demand. A site that answers 403 for a legally blocked resource is not wrong, just less specific — but if you see 451, the fix is never technical.
nginxnginx produces 403 from three unrelated places and the error log tells them apart in one line. `deny` in the access module refuses by client address; `open() ... failed (13: Permission denied)` is the filesystem refusing the worker user; and `directory index of "..." is forbidden` means a directory was requested, no index file exists and `autoindex` is off. Guessing between them costs far more than reading the log.
Reason phrase
Forbidden
Class
4xx client error
Defined by
RFC 9110
Standard
IETF standard

Tools on this site

All of these run in your browser — nothing is uploaded.

Related status codes

Codes people usually end up reading in the same session as 403.

Frequently asked questions

How do I find out which server produced this status code?

Read the response headers rather than the page. curl -sS -o /dev/null -D - https://example.com/path prints them without the body, and server, via and cf-ray between them name the layer that answered. A cf-ray value means Cloudflare handled the response and is the id their support will ask for. To take the edge out of the picture entirely, repeat the request with --resolve example.com:443:203.0.113.10, which connects to the origin address you name while still sending the original host name and SNI — if the answer changes, the edge and the origin disagree.

Does the reason phrase after the number matter?

No, and you should never branch on it. RFC 9112 tells clients to ignore the reason phrase, servers are free to change it, and HTTP/2 and HTTP/3 do not carry one at all — so 404 Not Found over HTTP/1.1 arrives as a bare 404 over HTTP/2. The phrases on this site are the registered ones because they are what people search for and what appears in an HTTP/1.1 log, not because any software depends on them.

Should I retry a request that failed with this code?

Retry 5xx and 429; do not retry 4xx, because nothing about the request will be different next time. If the response carries Retry-After, honour it — RFC 9110 defines it for exactly this, and it may be either a number of seconds or a date. Otherwise use exponential backoff with jitter and a hard cap, and only for idempotent methods: a retried POST can charge a card twice. A retry storm against a server that is already failing is how a brief incident becomes a long one.

Can I make my server return a different status code?

Yes, and the only rule that matters is that the code has to be true. Anything that reads your responses automatically — search engines, monitoring, caches, client retry logic — makes decisions from the number and never from the page. Serving an error page with 200 hides the failure from your own alerting; serving 200 for a missing page gets the error indexed as content; returning 500 for a request that was simply malformed sends whoever is on call to the wrong half of the stack.

Still stuck on 403? browse every status code in the reference — or go back up to the block written for your side of the stack.