403
How to fix HTTP 403 Forbidden
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.
Front-end developer
Why you are seeing it
A 403 arriving on a request the user is plainly signed in for has a small number of causes and they are rarely in your fetch call. A CSRF token that is missing, stale, or bound to a session that has since rotated is the most common: Django's `CsrfViewMiddleware`, for one, answers 403 rather than 401 for exactly this, so a page left open overnight starts failing its first POST while every GET still works. A pre-signed URL that has passed its expiry is the second — object storage answers 403 with an access-denied body once the signature's deadline has gone by, so a URL you cached and reused works for a while and then does not. The third is not your API at all: a WAF decided the request looked like an attack and returned its own HTML block page, so `res.json()` throws on a document that was never yours.
What to do
Before anything else, work out whose page you received. If the body is HTML rather than your API's JSON, read `server` and look for `cf-ray` — a branded block page with a ray id is an edge rule, and the ray id is the string support can look up. For the CSRF case, reload the page to obtain a fresh token instead of resubmitting the old one, and treat “works after a hard refresh” as a diagnosis rather than a fix. For signed URLs, request a new one at the point of use rather than storing it. And do not build a retry: §15.5.4 tells clients not to repeat the request with the same credentials, so a retry loop on 403 only multiplies the traffic your WAF already dislikes.
Back-end developer
Why you are seeing it
Either your own authorization layer refused, or something in front of it did, and the error log usually names which in one line. In nginx there are three distinct 403s that look identical from outside: a `deny` directive in the access module refuses by address; `open() "/path" failed (13: Permission denied)` is the filesystem refusing the worker user, which nginx maps to 403; and a request for a directory with `autoindex off` logs `directory index of "/path/" is forbidden` because there is no index file to serve and listing is disabled. On object storage the trap is different and deliberate: AWS documents that a caller lacking `s3:ListBucket` receives 403 Access Denied for a key that does not exist, rather than a 404, precisely so the response cannot be used to probe which keys are there.
What to do
Read the proxy's error log before touching any application code, because the three nginx cases above are three different fixes and the log line distinguishes them for free. Then establish whether the request reached your application at all: look for your own request id in your own logs, and if it never appears the refusal happened upstream and nothing in your authorization code is relevant. For the filesystem case, check the mode and owner of the file and then the execute bit on every parent directory, since a worker that cannot traverse an intermediate directory produces the same 403 as one that cannot read the file. For object storage, grant `s3:ListBucket` in a test policy if you need 404s to be distinguishable during debugging, and remember that the ambiguity is a feature in production. For a WAF, find the rule id on the block page and check it against the request body rather than the path.
End user
Why you are seeing it
The site knows what you asked for and has decided you may not have it. That is different from a broken link and different from a password problem — it is a refusal, and it is usually about which account, which place, or which network you are, rather than about anything you typed. Content limited to a region or to a paid plan, a shared document whose permission was granted to one address and opened from another, an invitation link that has expired, and a VPN or public network the site blocks all produce this same page.
What to do
Check which account you are signed in with first, because a link shared to a work address and opened in a browser signed into a personal one is the single most common cause and takes ten seconds to rule out. If you are on a VPN, a company network, or a public hotspot, try again without it, since some sites refuse whole ranges of addresses. If the page names a reference or ray id, keep that string — it is the one thing the site's operators can look up directly. And if the link came from someone else, ask them to share it again to your current address rather than retrying the one you have.
Reproduce 403 with curl
Run this against the URL that failed. It prints the response headers, the error page and the status, and it keeps the error page on purpose: on a 403 the body is often the only place the reason appears, so you see what the server said rather than what the browser rendered.
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 with | How to tell them apart |
|---|---|
| 401 | 401 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. |
| 404 | These 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. |
| 429 | A 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. |
451 | 451 (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. |
nginx | nginx 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.