intermediate 3 min answer Multiple choice

A penetration test reports that every object identifier in your API is a sequential integer and flags insecure direct object reference. Ownership is checked in shared middleware on every route. Three endpoints still returned another user's data: a bulk update that takes an array of identifiers, a nested comment fetch, and a CSV export. Where do you look first?

broken-access-controlidorobject-level-authorizationmiddlewaredebugging
Pick one
Show the full answer Hide the answer

The first three things I would look at

  1. How the middleware finds the resource it is authorising. Almost every implementation reads a path parameter, loads that record, and compares an owner field. That is one identifier per request.
  2. Which identifiers each failing endpoint accepts that are not in the path. The bulk update takes an array in the body. The comment fetch resolves a parent through a join. The export takes a filter. None of those identifiers pass the middleware's hands.
  3. Whether the data-access layer can be called without an authorisation context at all. If orders.find(id) works with no principal argument, every future endpoint is one line away from the same bug.

The diagnosis

The middleware authorises the primary resource named in the URL, and these three endpoints operate on resources named somewhere else. The bulk endpoint authorises the first identifier, or the collection, and then writes to all of them. The nested fetch authorises the comment and returns a parent the caller cannot see. The export authorises the report route and then runs a query whose predicate came from user input.

This is why a per-route check is the wrong shape of control. It sits at the edge of the request, and the number of resources a request touches is not one. The fix is to move authorisation to where records are loaded, so the repository refuses to return a row the principal cannot see, and an endpoint that forgets to ask gets an empty result rather than somebody else's data.

The misleading signal

The sequential integers. They are the loudest finding in the report and they are not the vulnerability. An unguessable identifier changes the economics of enumeration: a 122-bit random identifier cannot be walked, whereas sequential integers let one client enumerate a whole table at whatever rate your gateway permits, which for a typical limit of 100 requests per second is a million rows in under 3 hours. That is worth having as a second layer. It is not an authorisation control, and a team that renumbers to UUIDs and closes the finding has made the same data reachable by anyone who has ever seen a link. Broken access control has led the OWASP Top Ten since 2021 for exactly this reason: it is a design property rather than a bug to patch. Where the identifier genuinely is the control, as in an unauthenticated share link, it needs expiry and revocation to go with it.

Why the other options fail

  • Middleware registration order. A real bug with a real signature: when ordering is wrong, every route on that router fails, not three unrelated ones. Three scattered endpoints with different shapes points at what the check cannot see rather than at whether it ran.
  • The identifier format. Addressed above. Fixing it reduces mass enumeration and leaves targeted access intact.
  • A response cache keyed wrongly. This genuinely causes cross-user leaks and it leaks the whole rendered response at random, affecting endpoints indiscriminately. The three failures here are deterministic and reproducible with a chosen identifier, which rules the cache out.

When this is the wrong answer

If the API is a single service with 6 endpoints, pushing authorisation into the repository costs a day and is still right. Prefer the repository check unless the data layer is a third-party store you cannot wrap, in which case the alternative is one query builder every caller must use plus a lint rule that fails the build on a raw query. What does not work at any size is asking reviewers to remember.

What a strong answer adds

An alert that would have caught it before the pentest. Log the principal and the owner of every record the repository returns, compare them, and page on a non-zero count. That assertion is the only detector for a bug class that produces no errors.