Skip to content
Control Plane Labs

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.pdf

What 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