403 Forbidden
Client error response defined by RFC 9110 §15.5.4.
Last updated July 27, 2026
Common causes at a glance
- S3 or GCS object ACL and bucket policy denying the principal
- A WAF rule matching the request path, body, or user agent
- Signed URL expired or its signature computed over the wrong canonical request
- Application role check failing for an otherwise valid authenticated session
Reproduce it
curl -i https://assets.example.com/reports/2026-q1.pdfWhat 403 Forbidden means
403 is a decision, not a lookup failure. The server resolved the resource, worked out who is asking (possibly “nobody”), applied a rule, and said no. The rule can live anywhere: an application permission check, an S3 bucket policy, an IP allowlist, a WAF signature, a geo-restriction, a signed-URL expiry.
The hard part is that a well-behaved 403 deliberately tells you almost nothing. Leaking “this resource exists but you may not see it” is information disclosure, so servers are entitled to be vague. Debugging 403 is mostly about identifying which layer produced it, since the application, the CDN, the object store, and the WAF all emit 403 and none is obliged to explain itself.
What the spec says
RFC 9110 §15.5.4 says the server understood the request but refuses to fulfil it, and that if credentials were supplied the server considers them insufficient. Two clauses matter operationally. A client SHOULD NOT automatically repeat the request with the same credentials, which is why retry libraries must not treat 403 the way they treat 401. And an origin that wants to hide the existence of a forbidden resource MAY respond with 404 instead, so a 404 from a hardened system is not proof the path is wrong. Unlike 401 there is no mandatory header, because there is no challenge to issue; put a reason in the body as RFC 9457 problem details instead.
What actually causes it
On anything cloud-hosted, the odds favour object storage or a WAF over
your own code. S3 returns 403 for a missing object when the caller lacks
s3:ListBucket, so “access denied” frequently means “not found” — AWS
documents the decision tree in its
403 troubleshooting guide.
Signed URLs are the next suspect: they fail closed on expiry, on clock skew, and on any
signed header rewritten in transit. WAFs produce the most confusing 403s because they are
payload-sensitive; a parameter containing an apostrophe or the word select
trips a generic SQL-injection rule, so one form field breaks while everything else
works.
How to debug it
Identify the responder before anything else. Look at Server,
Via, and any vendor request-ID header:
curl -sD - -o /dev/null https://assets.example.com/reports/2026-q1.pdf \
| grep -iE '^(server|via|x-amz-|cf-|x-cache)'
An x-amz-request-id means S3 decided and a cf-ray means
Cloudflare was involved; neither means your application ran. Then bisect: request the same
path directly against the origin, and a known-public path from the same client. If the
public path works and the private one does not, it is policy. If nothing works from one
network, it is IP or geo filtering. For WAF suspicions, retry with the payload reduced to
plain alphanumerics — if that succeeds, a rule matched content, not identity. The
header inspector shows which hop rewrote a signed header.
Working examples
curl
# Who actually refused? Vendor headers give it away.
curl -sD - -o /dev/null https://assets.example.com/reports/2026-q1.pdf \
| grep -iE '^(server|via|x-amz-|cf-|x-cache)'
# Compare a public path from the same client to separate policy from network.
curl -o /dev/null -s -w '%{http_code}\n' https://assets.example.com/public/health.txt
# Bypass the CDN and ask the origin directly.
curl -i --resolve assets.example.com:443:203.0.113.10 \
https://assets.example.com/reports/2026-q1.pdf
Python (requests)
import requests
resp = requests.get("https://assets.example.com/reports/2026-q1.pdf", timeout=10)
print(resp.status_code)
# Attribute the refusal to a layer before debugging the wrong system.
for h in ("Server", "Via", "x-amz-request-id", "cf-ray", "x-amzn-errortype"):
if h in resp.headers:
print(h, "=", resp.headers[h])
# S3 puts a machine-readable reason in an XML body.
if "<Error>" in resp.text:
print(resp.text[:400])
Node (fetch)
const url = "https://assets.example.com/reports/2026-q1.pdf";
const res = await fetch(url);
if (res.status === 403) {
// Do not retry with the same credentials; RFC 9110 says it will not help.
for (const [k, v] of res.headers) {
if (/^(server|via|x-amz-|cf-ray|x-cache)/.test(k)) console.log(k, v);
}
console.log((await res.text()).slice(0, 400));
}
// A signed URL that 403s is usually expired: check the expiry parameter.
console.log(new URL(url).searchParams.get("X-Amz-Expires"));Reference and tooling
The authoritative list of assigned status codes is theIANA HTTP Status Code Registry. For a searchable copy with the semantics summarised, use theHTTP status code reference on this site. To see exactly what a server is sending, theheader inspector shows the raw status line and response headers, and thecurl builder assembles the request flags for you.
Other 4xx codes
- 400 Bad Request
- 401 Unauthorized
- 402 Payment Required
- 404 Not Found
- 405 Method Not Allowed
- 406 Not Acceptable
- 407 Proxy Authentication Required
- 408 Request Timeout
- 409 Conflict
- 410 Gone
- 411 Length Required
- 412 Precondition Failed
- 413 Content Too Large
- 414 URI Too Long
- 415 Unsupported Media Type
- 416 Range Not Satisfiable
- 417 Expectation Failed
- 418 (Unused)
- 419 Page Expired
- 421 Misdirected Request
- 422 Unprocessable Content
- 423 Locked
- 424 Failed Dependency
- 425 Too Early
- 426 Upgrade Required
- 428 Precondition Required
- 429 Too Many Requests
- 431 Request Header Fields Too Large
- 444 No Response
- 451 Unavailable For Legal Reasons
- 494 Request Header Too Large
- 495 SSL Certificate Error
- 496 SSL Certificate Required
- 497 HTTP Request Sent to HTTPS Port
- 499 Client Closed Request