skip to content

If you answer 404 rather than 403 to hide a record's existence, at which pipeline stage must that decision be made?

level: seniorimportance: must knowfreq 48%

answer

  1. hide existence, not the rule
  2. only a loaded record can be hidden
  3. handler decides, response stage formats
  4. a blanket rewrite over-masks
  5. timing and headers leak it back

basics

~20 s

Make it where both facts are known: in the handler, after the record loads and the visibility rule runs. A pre-handler hook cannot mask what it never loaded, and rewriting every refusal into 404 over-masks failures unrelated to hidden records.

solid answer

~50 s

Masking is a claim about **indistinguishability**: the response for a record that does not exist and the response for one the caller may not see have to look the same. Only a stage holding both facts can produce that, and the only such stage is the handler after its lookup - it loads the record, applies the rule, and on refusal raises exactly the same failure the absent case raises. A pre-handler rights hook holds the caller and the route but not the row, so the most it can do is blanket-refuse a whole prefix. A blanket rewrite of every `403` into `404` at the response-writing stage is the tempting shortcut, and it over-masks: refusals about scope, plan or account state get hidden too, and clients lose any way to know they should ask for access. Masking is also only real if timing, headers and body shape match.

go deeper

for a junior

Know that some APIs answer not-found instead of forbidden to avoid confirming that a record exists, and that this is a deliberate choice rather than something the framework does for you.

for a middle

Be able to explain why the decision has to sit after the lookup: a stage that has not loaded the record cannot tell the absent case from the hidden one, so it cannot make them look alike.

for a senior

Demonstrate the full job: identical failure path, identical body and headers, comparable timing, and the true reason preserved in logs and metrics behind a correlation id.

for a principal

Own the policy boundary. Decide which resource classes mask and which do not, price the support and integration cost, and make the shape inheritable so a single non-conforming endpoint cannot undo it.

Choosing to answer "not found" for a record the caller is not allowed to see is a policy decision. *Where in the request pipeline that policy can physically be enforced* is a mechanical one, and the mechanics constrain the policy more than most teams expect. ## What masking has to achieve The goal is not a status code. The goal is that two situations produce responses a caller cannot tell apart: - the record does not exist; - the record exists and the caller may not see it. If any observable difference survives - status, body, headers, latency, a correlation id format, a counter the caller can read back - the mask has a hole and the policy has bought nothing. ## Only a stage holding both facts can decide | Candidate stage | Knows the caller | Knows whether the record exists | What it can actually do | |---|---|---|---| | Pre-handler rights hook | yes | no | Refuse the whole route or prefix uniformly | | Handler, after the lookup | yes | yes | Emit the identical answer for both cases | | Failure-to-response stage | indirectly | no | Rewrite refusals uniformly, without knowing why | The middle row is the only one that can enforce the policy as stated. The first row can approximate it by refusing an entire prefix the same way for everyone, which is a coarse tool. The last row can rewrite, but it is downstream of the only stage that knew the difference. ## The handler shape 1. Load the record by its identifier **without** applying the visibility rule. 2. If it is absent, raise the not-found failure. 3. If it is present, evaluate the visibility rule. 4. If the rule refuses, raise the **same** not-found failure - not a different failure that happens to map to the same status. 5. Let the shared response component format it exactly as it formats the absent case. Step 4 is the one people skip. Two distinct failure types tend to diverge somewhere downstream: a different error code in the body, a different log field that ends up echoed, a header set on one path only, a metric label that appears in a publicly scraped endpoint. ## Why a blanket rewrite over-masks Not every refusal is about a hidden record. A single global rule that turns every `403` into `404` also hides: - a caller whose credential is valid but whose granted scope does not cover this operation; - an account that is suspended, unverified or out of quota; - an operation refused on a record the caller can otherwise read; - a refusal that the client is genuinely expected to act on by requesting access. All of those become indistinguishable from a typo in the URL. Support load rises, client integrations cannot self-diagnose, and if the rewrite sits *before* logging you have destroyed your own evidence as well. ## Side channels that re-leak existence - **Timing.** A hidden record costs a lookup; an absent one may short-circuit earlier. Measurable difference, measurable oracle. - **Headers.** Validators, cache directives, `Allow`, `Location` and similar headers set on one path and not the other. - **Body shape.** A different error code, message, or the presence of a field only the populated path fills in. - **Accounting.** Rate-limit counters, quota decrements or audit entries that only the existing record triggers. - **Well-formed versus unknown identifiers.** If a malformed identifier answers differently from a well-formed unknown one, an attacker learns the identifier space before probing it. ## Keep the truth on the inside Masking is a property of the wire, not of the system. Internally: - log the real reason with a correlation id, and return that id to the caller so support can join the two; - count masked refusals separately from genuine absences, or you will never see an access-control problem in a dashboard; - give operators a tool that answers "does this exist, and why can this user not see it", because the API deliberately no longer can. ## Where masking does not belong Do not mask the authentication failure. A caller who sent no credential needs the challenge; hiding it as not-found means a legitimate client never learns to authenticate and retries forever. Do not mask resources whose existence is already public, such as a catalogue entry or a published document - it costs client experience and hides nothing. And apply the policy per resource class rather than per endpoint, or the one endpoint that answers differently will tell an attacker exactly which resources you considered worth hiding.

  • Why is a global rewrite of every refusal into not-found usually the wrong knob?
    Because it cannot tell a hidden-record refusal from a scope, quota or account-state refusal - it sees a failure, not the reason. Those refusals are ones the client is supposed to act on, and hiding them makes integrations undiagnosable. If the rewrite also runs before logging, the reason is lost internally too.
  • How can two responses that are both 404 still reveal which record exists?
    Through everything that is not the status: response latency, validator or cache headers set on only one path, a different error code or message in the body, a correlation id format, and accounting the caller can observe such as a rate-limit counter that only decrements when a record was actually read.
  • Should the masked response be cacheable?
    Treat it as private and non-shareable. A masked refusal is caller-specific by definition, so a shared cache could serve one caller's not-found to another who is allowed to see the record, or the reverse. Marking the absent case and the masked case identically also means the cache directives must match, or the directives themselves become the oracle.

saying these in an interview costs you the question

  • Believes a pre-handler hook can choose 404 over 403 without loading the record
  • Rewrites every refusal into 404 globally and calls the masking done
  • Raises a distinct failure type for the hidden case and expects identical output
  • Ignores response timing and headers as existence side channels
  • Masks the authentication failure too, so clients never learn to present a credential
  • Drops the real reason from the logs as well as from the response