How HTTP Works

Comparison

403 vs 404: Forbidden or Not Found

403 Forbidden vs 404 Not Found: when to admit a resource exists, when to hide it, the GitHub private-repo example, and the debugging cost of masking 403 as 404.

Bottom line: Use 404 when the resource does not exist, or when its existence is itself a secret. Use 403 when the caller is entitled to know the resource is there but may not touch it.

By How HTTP WorksReview process
403 Forbidden
vs
404 Not Found

TL;DR: Return 404 when the resource does not exist, or when admitting it exists would leak something. Return 403 when the caller is allowed to know the resource is there but is not permitted to access it. RFC 9110 explicitly lets a server answer 404 to hide a forbidden resource, and GitHub does exactly this for private repositories.

Side By Side

403 Forbidden404 Not Found
SaysI understood the request and refuse itI have nothing here (or will not say)
Confirms the resource existsYesNo
Fix for the callerGet permission, a different role, a different IPFix the URL, or accept it is gone
Cacheable by defaultNoYes (heuristically cacheable, RFC 9110 section 15.5.5)
Retry after logging inPossiblyPossibly, if the 404 was masking a 403
Logged asPermission signal worth alerting onMostly noise from crawlers and broken links

The Decision

Ask whether the caller is allowed to learn that the resource exists.

  1. The resource really does not exist: 404.
  2. It exists and the caller may know that, but cannot act on it (a shared document in view-only mode, an admin button for a member): 403.
  3. It exists and the caller must not even learn that (another tenant’s invoice, a private repo, a user ID that could be enumerated): 404, with the same body and headers as a genuine miss.

Raw Exchanges

An invoice that belongs to another tenant. Both cases below give the attacker the same answer:

GET /api/invoices/inv_90210 HTTP/1.1
Host: api.example.com
Authorization: Bearer eyJhbGciOi...
HTTP/1.1 404 Not Found
Content-Type: application/json

{"error": "not_found"}

The same request for inv_00000 (which truly does not exist) must return byte-identical content, otherwise the body itself is an oracle. Compare a document the user can see but not edit:

PUT /api/documents/doc_17 HTTP/1.1
Host: api.example.com
Authorization: Bearer eyJhbGciOi...
Content-Type: application/json

{"title": "Q4 plan"}
HTTP/1.1 403 Forbidden
Content-Type: application/json

{"error": "forbidden", "detail": "Requires editor role on this document."}

Here the user already saw the document in a list, so denying existence would just confuse them.

GitHub As The Reference Case

GitHub’s REST API documentation states that it “uses a 404 Not Found response instead of a 403 Forbidden response to avoid confirming the existence of private repositories.” In practice:

curl -i https://api.github.com/repos/some-org/private-repo

returns 404 Not Found for an unauthenticated caller. A debugging consequence: when you hit an unexpected 404 from GitHub (or a CI token step fails with “repository not found”), check the token’s scopes, SSO authorization for the org, and repo access before assuming the URL is wrong. A genuinely missing repo and a repo you cannot see are indistinguishable by design.

Common Mistakes

Hiding existence in one place and leaking it in another. The 404 on GET /projects/{id} is undone if POST /projects returns “name already taken in another org,” or a search endpoint autocompletes the private name.

Different bodies or timing. A 404 with {"error": "no such invoice"} for a miss and {"error": "not_found"} for a masked 403 reveals the difference. Database lookups that return fast for misses and slow for permission checks add a timing signal. Keep both paths on the same code and response shape.

Using 404 everywhere and losing debuggability. Developers and support staff can no longer tell a typo from a permission bug. Log the real reason server-side with a request ID, and include that ID in the response so support can look it up.

Using 403 for unauthenticated callers. If the caller has not authenticated at all and could succeed by logging in, the answer is 401 with WWW-Authenticate. See 401 vs 403.

Returning 403 for rate limits or bot blocks without a body. 429 is the rate-limit code, and a WAF block should say which rule fired in its own page or Server signature, so people can tell it apart from an application permission error.

403 on missing S3 keys. Without s3:ListBucket, S3 answers missing objects with 403 rather than 404. It looks like a permission bug, and it is S3 hiding existence the same way. Grant list access if you want honest 404s.

Soft 404s. A “page not found” page served with 200 is neither of these; search engines treat it as a soft 404, and clients cannot tell it from success.

Quick Debug

curl -i https://example.com/admin/reports
curl -i -H 'Authorization: Bearer <token>' https://example.com/admin/reports

If the first call is 404 and the second is 200, the route exists and the 404 was masking missing authentication. Compare with the 403 reference at HTTP 403 Forbidden and HTTP 404 Not Found.

FAQ

Should I return 403 or 404 for resources a user is not allowed to see?

If the existence of the resource is sensitive (private repositories, other tenants’ records, admin-only routes, account lookups), return 404 so the response does not confirm it. If the user can already see that it exists, for example a listed document they lack edit rights on, return 403 so they know to request access. RFC 9110 section 15.5.4 explicitly allows an origin server to use 404 to hide a forbidden resource.

Does GitHub return 404 for private repositories?

Yes. GitHub’s REST API documentation says it uses a 404 Not Found response instead of 403 Forbidden to avoid confirming the existence of private repositories. So an unauthenticated request for a private repo and a request for a repo that does not exist look the same, The same masking commonly shows up when a token lacks access to a private repo.

Is 404 instead of 403 actually secure?

It closes the direct existence oracle but not every side channel. Timing differences between “not found” and “found but denied” code paths, different response sizes or headers, and other endpoints (search, autocomplete, sharing dialogs) can still leak existence. It is defense in depth, not a substitute for authorization checks.

Why do I get 403 for a page that loads fine in my browser?

The server or a WAF is deciding on something other than the URL: your IP, a missing or odd User-Agent, a missing Referer, a geo block, or a bot-detection rule. Compare the exact headers curl sends against those your browser sends, and check for a Cloudflare or WAF block page, which will name the rule.

Should a missing file on a static site be 403 or 404?

It should be 404. Some storage backends return 403 for missing keys when the caller lacks list permission, such as an S3 bucket without s3:ListBucket, precisely to avoid revealing whether keys exist. If you see 403 on missing objects there, check the bucket policy.

Which should a deleted resource return, 404 or 410?

410 Gone tells clients and crawlers the removal is deliberate and permanent. 404 says nothing about permanence. Neither has anything to do with 403, which is about permission.

References

Browse /search