403
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.
The same three digits are three different problems depending on which side of the request you are on. Read the block that describes you.
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.
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.
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.
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 -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.
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.
| 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. |
All of these run in your browser — nothing is uploaded.
Codes people usually end up reading in the same session as 403.
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.
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.
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.
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.