At a glance
- Controls
- OWASP API1:2023 · SOC 2 CC6.1 · ISO 27001 A.9.4
Almost every product is a set of objects that belong to someone: orders, invoices, messages, files, projects. The rule that a person may touch only their own objects is so obvious that it is rarely written down, and code that is never told to check ownership does not check it. The endpoint fetches the object by its identifier, returns it, and looks completely correct while doing so.
Why it happens
A coding assistant asked for a handler that returns an order by its identifier writes exactly that. It reads the identifier from the path, queries the row, and sends it back. The prompt described the happy path, the model produced the happy path, and the ownership check was never part of the request. The generated code matches the style of the handlers around it, so a reviewer sees a familiar shape and reads past it.
The gap widens with velocity. When a team ships one handler a quarter, a human eventually notices the missing check. When an assistant ships a dozen a week, each new route is another chance for the same omission, and the review that used to catch it is now the bottleneck everyone is trying to route around.
How the attack unfolds
An attacker signs up for an ordinary account and watches the requests the product makes on their behalf. Any request carrying an identifier is a candidate: an order number, a user id, a file key, a sequential integer or a guessable string. They replay the request with a different identifier and read the response. If the object comes back, the endpoint is trusting the identifier alone, and the same weakness usually extends from reads to writes: update, export, cancel, refund, delete.
The endpoint was not broken into. It answered exactly the question it was asked, for an object that was never the caller’s to see.MATT research team
Why review misses it
Review reads for intent and for visible mistakes. A missing authorisation check is neither: nothing on the screen is wrong, and the intent looks satisfied because the object is returned. Static analysis has the same blind spot, because the code is syntactically clean and only semantically wrong. The defect is an absence, and absences do not show up in a diff.
Where it hides
What a reviewer can look for in the code and in the behaviour, before anyone proves it.
- A handler queries an object by an identifier taken from the path, query or body, with no clause tying the query to the current user or tenant.
- Authorisation is checked at the route (is the caller signed in) but not at the object (does this object belong to them).
- Object identifiers are sequential or otherwise guessable and appear directly in URLs.
- The same object is reachable from more than one route, and only one of them enforces ownership.
A real instance
Details altered to protect the customer. Pattern, timing and outcome are as found.
How MATT tests it
The proof runs against the deployed product, on every release, whoever wrote the code.
- 01Enumerate every endpoint that accepts an object identifier in the path, query, body or a header.
- 02Request each with a second account’s identifier and compare the responses byte for byte.
- 03Chain a successful read into the write paths that share the identifier: update, export, cancel, delete.
- 04Repeat after every release, because new handlers are the usual source of a fresh instance.
The evidence
- GET /api/orders/48213200 OKReturns another customer’s order: email, total, card last four
- POST /api/orders/48213/refund200 OKRefund issued on an order the caller does not own
- GET /api/orders/48213403 ForbiddenOwnership enforced at the data layer for every order route
- POST /api/orders/48213/refund403 ForbiddenSame rule holds on the write path, verified on every release
A user must never be able to read or act on another user’s object.
Executable checkFor every endpoint accepting an object identifier, a request carrying a foreign identifier must return 403 or 404, on every release.
Every rule MATT holds →What the fix looks like
- 01
Enforce ownership once, at the data layer
Scope the query itself to the current user or tenant, so every route that loads the object inherits the check and no future handler can forget it.
- 02
Make identifiers unguessable
Prefer opaque identifiers over sequential integers, so enumeration is harder even where a check is briefly missing.
- 03
Turn it into a rule that runs
Every endpoint that accepts an object identifier must return 403 or 404 for a foreign identifier, checked on every release.
A check that was never written is found by proving the deployed behaviour, fixed with the engineer who shipped it, and kept fixed by running the control on every change. What is ExploitOps? →