skip to content

How do you decide between HTTP status 401, 403 and 404 when a caller is refused, and when would you deliberately return 404 for something that exists but the caller may not see?

level: middleimportance: must knowfreq 64%

answer

  1. 401 = unauthenticated + WWW-Authenticate
  2. 403 = authenticated, still refused
  3. Expired token → 401, never 403
  4. Existence secret? → 404 for both cases
  5. Scope the query by tenant; log reason + correlation id

basics

~20 s

401 means "I don't know who you are — authenticate" and must carry WWW-Authenticate. 403 means "I know who you are and you still may not". 404 hides existence: return it instead of 403 when merely confirming a resource exists leaks information.

solid answer

~50 s

- **401 Unauthorized** — authentication is missing, malformed, or expired. It is a challenge, so the response must include `WWW-Authenticate`. Retrying with valid credentials can succeed. - **403 Forbidden** — the caller is authenticated and still not permitted. Re-authenticating does not help; the answer is the same until permissions change. - **404 Not Found** — nothing here. Used honestly when the resource does not exist, and used *deliberately* in place of 403 when the existence of the resource is itself sensitive. The information-hiding case matters in multi-tenant systems: if `GET /orders/9931` returns 403 for another tenant's order and 404 for an unused id, an attacker can enumerate which ids are real. Answering 404 for both makes the two indistinguishable. The tradeoff is debuggability, so log the true reason server-side with a correlation id returned to the caller. Use 403 openly where existence is not a secret and the user could plausibly gain access.

go deeper

for a junior

Know 401 = not authenticated, 403 = authenticated but not allowed, 404 = not found, and that an expired token is a 401.

for a middle

Add the WWW-Authenticate requirement and explain when 404 substitutes for 403 to avoid confirming a resource exists.

for a senior

Cover the implementation trap — caller-scoped queries, identical timing and bodies — plus server-side logging with correlation ids for support.

for a principal

Make it a platform policy enforced in shared middleware so no service becomes the chatty one that leaks existence for everyone.

## The three questions A refused request fails one of three checks: 1. **Who are you?** — no credentials, expired token, bad signature → **401 Unauthorized** (badly named; it means unauthenticated). 2. **May you?** — identity established, permission denied → **403 Forbidden**. 3. **Does it exist / may you know it exists?** → **404 Not Found**. ## 401 details that get missed 401 is formally a *challenge*: the response must include `WWW-Authenticate` telling the client how to authenticate. ``` HTTP/1.1 401 Unauthorized WWW-Authenticate: Bearer realm="api", error="invalid_token", error_description="expired" ``` This matters in practice: SDKs and browsers use it to decide whether to run a refresh flow. An API that answers 401 with no header leaves clients guessing, and one that answers 403 for an expired token breaks automatic refresh entirely — the client concludes the user lacks permission and logs them out. "Expired token → 401, not 403" is the single most common real bug in this area. Do not put fine-grained reasons in the 401 body. "Unknown user" versus "wrong password" on a login endpoint is a user-enumeration oracle; return one generic failure for both. ## 403 details 403 says: authenticated, understood, refused, and repeating with the same identity will not help. Legitimate uses include role checks, disabled accounts, IP allowlists, and quota/plan restrictions. How much to explain is a judgement call. For an internal or partner API, `{"code":"MISSING_SCOPE","required":"orders:write"}` is enormously helpful and leaks nothing an authenticated partner should not know. For a public API where the mere existence of a resource is sensitive, even "you lack access to order 9931" confirms the order exists. ## The 404-instead-of-403 decision The question to ask is: **is existence itself a secret?** - **Yes** — private repositories, other tenants' records, documents shared by link, user accounts by email. Confirming existence enables enumeration, targeted phishing, or competitive intelligence. Answer **404**, identically for "does not exist" and "exists but not yours", and make sure the timing and body are identical too — a fast 404 and a slow 404 is still an oracle. - **No** — an admin-only endpoint everyone knows about, a feature gated by plan tier, a document in a shared workspace where membership is public. Answer **403** and say why; hiding it just makes support tickets. A critical implementation detail: the check must happen *before* the response differs in any observable way. Fetching the resource, then returning 403 on a permission failure and 404 on a miss, is the leak. The correct shape is a single query scoped to the caller (`WHERE id = ? AND tenant_id = ?`) whose empty result is a 404 by construction — that also prevents the accidental data exposure that comes from loading the row first. ## Keeping debuggability Hiding from the attacker should not mean hiding from your on-call engineer. Log the real reason with the identity, resource id and decision, and return a correlation id in the response body or a header. Support can then answer "why did I get a 404" by looking it up, without the API having said anything to the caller. ## Codes that live nearby - **400** — the request itself is malformed; not an authz decision. - **405 Method Not Allowed** — the URL exists but not with that method; must include `Allow`. - **429 Too Many Requests** — throttled, not forbidden. Using 403 for rate limiting confuses clients that would otherwise back off. - **451** — blocked for legal reasons, a deliberate variant of 403. ## Consistency across the platform Mixed behaviour is itself an oracle: if one service returns 403 for a cross-tenant read and another returns 404, an attacker uses the chatty one. The decision belongs in shared middleware or a policy layer, expressed as a rule ("cross-tenant access is always 404") rather than left to each controller.

  • What should an API return when a bearer token has expired?
    401 with a WWW-Authenticate header indicating an invalid or expired token. Clients and SDKs key their refresh flow off 401; answering 403 makes them treat it as a permission problem and typically log the user out instead of refreshing. Keep the reason coarse in the body — enough to trigger refresh, not enough to enumerate.
  • Does returning 404 instead of 403 actually stop enumeration?
    Only if everything observable is identical: the same status, the same body, and comparable response time, across both the not-found and not-permitted paths. If the forbidden path does a database lookup and the missing path short-circuits, timing still distinguishes them. The robust implementation runs one caller-scoped query so the two cases genuinely take the same route.

saying these in an interview costs you the question

  • Returning 403 for an expired or missing token, breaking client refresh flows
  • Sending 401 without a WWW-Authenticate header
  • Distinguishing "unknown user" from "wrong password" on a login endpoint
  • Returning 403 for a resource whose existence is confidential, enabling id enumeration
  • Loading the resource first and then choosing 403 or 404, leaking existence via timing or logs

context