skip to content

An endpoint returns an invoice given its identifier, and any logged-in user who supplies another customer's identifier receives their invoice. Explain why this class of defect is so common, state the enforcement rule that prevents it, and say why switching to random unguessable identifiers is not the fix.

level: middleimportance: must knowfreq 76%

answer

  1. function-level free, object-level hand-written
  2. client designates, never asserts entitlement
  3. scope every query by principal - unrepresentable
  4. unguessable ids leak; obscurity is not a control
  5. deny by default; negative tests per resource

basics

~20 s

Frameworks give you endpoint-level checks for free, but per-record checks need domain knowledge, so they get omitted. The rule: every request re-derives permission from server-owned state - the request says which object, never whether it is allowed. Unguessable identifiers are obscurity; identifiers leak, and then there is no check at all.

solid answer

~50 s

There are two layers. Function-level authorization asks whether this principal may call this operation, and frameworks make it declarative, so it usually exists. Object-level authorization asks whether this principal may touch this particular record, and it depends on domain facts - ownership, tenancy, delegation, record state - so it must be written by hand in every handler. A hand-written check that is silently absent looks exactly like working code, which is why this defect dominates. The rule is that the decision comes from server-owned state on every request. The client may designate the object; it must never assert entitlement, tenancy, role or price. Random identifiers are not a control. They leak through shared links, referrers, exports, logs, support tickets and other endpoints' responses, and because no check exists, one leak is permanent access. They also do nothing about the mutation side, where the attacker supplies an owner rather than guessing one. The durable fix is structural: scope every query by the principal so a cross-tenant read is unrepresentable.

go deeper

for a junior

State that the handler must confirm the record belongs to the caller, and that hiding the identifier is not a check.

for a middle

Separate function-level from object-level authorization and explain why the latter is the one that gets omitted.

for a senior

Push enforcement into a scoped data-access layer, cover the mutation variant, and require negative tests per resource.

for a principal

Choose an enforcement architecture where cross-tenant access is unrepresentable, and decide the existence-disclosure policy explicitly.

## Why it is the most common serious defect The function-level rule ('only a billing role may call this') is uniform, declarative and enforceable in one place, so it gets written. The object-level rule ('this invoice must belong to the caller's account') differs per resource and needs domain knowledge, so it lives inside handlers. Missing code raises no error, no type check and no test failure unless someone wrote a test for the negative case. Meanwhile all the traffic is authenticated, so logs and anomaly detection see nothing unusual. The bug is invisible from every direction except a deliberate test. ## The enforcement rule Every request re-derives the authorization decision from state the server owns, for every object it touches. Client input designates - which record, which page, which field - and never asserts entitlement. A request may say 'invoice 981'; it may not effectively say 'as tenant 7' or 'with role admin'. The same rule catches the mutation variant, where a create or update carries an owner, tenant or price field that the handler binds straight onto the entity. And the check must cover every object touched, not just the top-level one: nested identifiers, bulk operations, export jobs and search results are each an access decision. ## Ranking the defences Use the same ladder as elsewhere in secure design, weakening from guarantee to heuristic as you descend. There is no escaping or transformation rung here because no parser is involved; the rungs are: 1. **Structural separation.** Make the violation unrepresentable. Data access goes through a layer that cannot be called without the principal's scope, so every query is filtered by tenant or owner by construction. A developer who forgets is unable to express the unscoped query. Enforcement in the data store itself is the same idea pushed one level down. 2. **Enumerated, code-owned permission checks.** A single authorization component every handler must consult, with resource types and actions drawn from a closed set the code owns. A real guarantee about which decisions exist, though it still relies on the call being made. 3. **Per-handler checks.** Correct when present. The failure mode is silent omission, and coverage decays as the codebase grows. 4. **Detection.** Audit logs, alerting on cross-tenant access patterns, negative tests in the pipeline. Valuable, but it tells you afterwards. Unguessable identifiers do not appear on this ladder at all, because they change the probability of an attacker finding a target rather than the outcome when they do. They are worth having for other reasons - sequential identifiers leak business volume and make enumeration cheap - but as an access control they are obscurity: identifiers appear in shared URLs, browser history, referrer data, exports, log aggregation, support screenshots and adjacent endpoints' responses, and once one escapes, access is permanent because nothing checks. ## Deny by default, and the existence question New endpoints must be denied unless a rule permits them, so forgetting to classify a route fails closed rather than open. When a check fails, decide deliberately what the response reveals: distinguishing 'exists but forbidden' from 'does not exist' hands an attacker an existence oracle over identifiers, which matters wherever mere existence is sensitive. Returning the same not-found answer for both is often right; the important part is that it is a decision, not an accident. ## Testing it The only reliable coverage is negative and systematic: for each resource, a test where principal A requests principal B's object and expects a denial. Because the defect is an absence, a per-resource test matrix is what turns 'we remembered' into 'the pipeline checks'.

  • How does the same defect appear on write operations rather than reads?
    The attacker supplies fields they should not control - an owner or tenant identifier, a role, a status, a price - and the handler binds the request body onto the entity. They do not need to guess anything, because they assert the value rather than discovering it. The fix is the same rule from the other side: bind only fields the principal is permitted to set, and take ownership and tenancy from server-owned state.
  • Why is enforcing the check inside the data-access layer stronger than putting it in each handler?
    Because it changes the failure mode from silent omission to impossibility. If every query must be issued through a scoped repository or filtered session, a developer cannot express an unscoped read, so new code inherits the control rather than needing to remember it. Per-handler checks are correct when written, but their coverage depends on discipline and decays as the surface grows.

saying these in an interview costs you the question

  • Treating random or opaque identifiers as an access control.
  • Checking only that the endpoint requires a role, and calling record-level access covered.
  • Trusting a tenant or role value supplied in the request because it came from an authenticated client.
  • Assuming the frontend not rendering a link prevents the request from being made.

context