HTTP 403 is Forbidden: the server understood the request and refuses to carry it out. RFC 9110 section 15.5.4 adds that if the request carried credentials, the server considers them insufficient, and the client shouldn’t repeat the request with the same ones. Unlike a 401, a 403 needs no WWW-Authenticate header, and signing in again won’t change the answer.
What sends it
- Your app, for a signed-in user without the right role, or a token without the right scope; RFC 6750 pairs
insufficient_scopewith 403. - nginx, when a
denyrule matches, when a directory has no index file andautoindexis off, and when its worker can’t read the file (Permission deniedin the error log). - Django, when a
POSTfails the CSRF check. - S3, for a key that doesn’t exist, when the caller lacks
s3:ListBucket. A static site on S3 behind CloudFront therefore shows 403 for pages that simply aren’t there. - AWS WAF, whose block action answers 403 by default, and API Gateway REST APIs, whose
Missing Authentication Token403 usually means the path or method doesn’t exist.
403, 401 or 404
not signed in, or the token expired 401
signed in, not allowed 403
not allowed, and its existence is secret 404
RFC 9110 lets a server answer 404 instead of 403 to hide that a resource exists. GitHub’s API does this for private repositories, so an outsider can’t probe for their names. A 403 confirms the thing is there.
What the client should do
Not retry. Tell the user they lack access, offer a way to ask for it or to sign in as someone else. A 403 from a WAF or CDN often arrives as an HTML page even when you asked for JSON, so check Content-Type before parsing the body.
Caching and search
403 isn’t heuristically cacheable. Google drops pages that answer 403 from its index, as it does for every 4xx except 429, and asks sites not to use 401 or 403 to slow its crawler. To ask Googlebot to back off, answer 429 or 503 for a while.
In your stack
if (!req.user.roles.includes('admin')) return res.status(403).json({ error: 'forbidden' }); // Express
location /internal/ {
allow 10.0.0.0/8;
deny all; # everyone else gets 403
}
raise PermissionDenied # Django: renders 403.html
abort(403) # Flask
http.Error(w, "forbidden", http.StatusForbidden)