Login Is Not Permission
The single most common serious flaw we find in Australian web applications is not injection and not a missing patch. It is broken object level authorization, still widely known by its older name, insecure direct object reference, or IDOR. The application correctly confirms that you are a logged-in user, then hands you whatever record your request names, without ever checking that the record is yours to see. Authentication answers who you are. Authorization answers whether you are allowed to touch this specific object. Most teams build the first properly and quietly skip the second.
The mechanics are mundane. A request arrives for the invoice at path /api/invoices/48213. A middleware layer confirms the session is valid, the handler loads invoice 48213 from the database, and the response goes back. Nowhere in that path does anything ask whether invoice 48213 belongs to the customer making the request. Change the number to 48214 and you read a stranger's invoice. That is why it is the flaw we find most: it survives the login screen, the multi-factor prompt, and the web application firewall, because from the outside it looks like a perfectly authenticated, perfectly ordinary request.
Why the Check Goes Missing
The reason this is so common is structural, not careless. Modern frameworks make authentication a cross-cutting concern: one guard, one decorator, one middleware applied globally, and every route is protected at once. Object level authorization cannot be centralised the same way, because whether user A may see object B depends on data, the ownership relationship between them, which only the individual handler knows. So the framework locks the front door of the building and leaves each room inside unlocked.
It also passes every test written the obvious way. A developer logged in as themselves loads their own dashboard, their own orders, their own profile, and everything works. The bug only surfaces when someone requests an identifier the interface never handed them, and nothing in normal use does that. The data layer makes it worse. An idiomatic call such as repository.findById(id) returns the record whether or not it belongs to the caller, so the natural code is the vulnerable code. Getting it right takes a deliberate extra step that the framework never forces you to write.
UUIDs Do Not Fix It, and Other Myths
A common defence is to swap sequential integers for random UUIDs on the theory that an attacker cannot guess them. This raises the cost of blind enumeration, but it is not access control. Identifiers leak constantly: in shared links, email notifications, referer headers, exported files, browser history, the API responses for related objects, and application logs. Once an identifier is known, an unguessable ID protects nothing, because the server still never checks ownership. Treating obscurity as authorization is how a system that felt hard to guess becomes a mass data exposure the moment a single ID escapes.
The flaw also hides well beyond the obvious read-by-id. The places worth checking first:
- Mass assignment and nested writes, where an update quietly sets a field such as owner_id, tenant_id, or role that the user was never meant to control.
- Secondary channels: PDF generators, CSV exports, webhook payloads, and background jobs that fetch by id with none of the checks the main API applies.
- GraphQL and batch endpoints, where one query walks from an object the user owns to related objects they do not.
- Function level gaps, the vertical cousin: an ordinary user calling an admin-only action that was hidden in the interface but never enforced on the server.
Horizontal and Vertical
The distinction worth holding onto is horizontal versus vertical. Horizontal is reaching another user's object at your own privilege level, their invoice, their message, their patient record. Vertical is reaching a higher privilege function, the admin panel or the billing override. Both are authorization failures, both are invisible to a scanner, and both must be enforced on the server for every request. A control that lives only in the client, a hidden button or a disabled field, is not a control at all, because the request that button would have made can be sent by hand.
Treat every identifier the client sends as attacker-controlled, because it is. The identifier in the URL, the one in the JSON body, and the one buried three levels deep in a nested object are all equally under the caller's control the moment the request leaves the browser.
How We Test for It Systematically
Finding these by clicking around is unreliable; it needs a method. The core technique is two accounts and disciplined replay. Provision two low-privilege users with separate data, call them A and B. Log in as A and exercise the whole application through an intercepting proxy, capturing every request and the identifiers in each: path parameters, query strings, request bodies, headers, and any IDs carried inside JSON. That inventory of object references is the test surface, and enumerating it fully is most of the work.
Then replay A's requests using B's session token, substituting A's identifiers, and read the response. A correctly built system returns 403 or 404 for every object B does not own. Any 200 carrying A's data is a confirmed finding. Run it in both directions to catch asymmetric bugs, and do not stop at the primary read endpoints; repeat for update, delete, export, and every secondary channel. For the vertical case, capture an admin action separately and replay it with a standard user's token.
A few disciplines separate a real test from a sampled one. Enumerate every parameter, not just the obvious id at the end of the path, because ownership bugs hide in the second and third identifiers of a nested route. Test the state-changing verbs, since an object you cannot edit through the interface is often writable through the API. And automate the replay where the ID space is large, because a manual spot check will miss the one endpoint out of forty that forgot its guard.
Fixing It Where It Belongs
Patching endpoints one finding at a time is a losing game, because the next feature reintroduces the same gap. The durable fix moves authorization into the data-access layer so ownership is enforced by construction rather than by memory. In practice that means queries are always scoped to the caller's identity. The handler does not load invoice 48213 and then check the owner; it loads the invoice where the id is 48213 and the owner is the current tenant, in a single query that returns nothing when the object is not theirs. The fetch and the check become the same operation, so no code path can fetch without checking.
Above that layer, a central policy component that denies by default gives one place to reason about who may do what to which object, instead of scattering the logic across handlers. Where the data store supports it, row level security in the database is a strong backstop: even a handler that forgets to scope its query cannot return rows the current role is not entitled to. On Azure and M365 estates the same principle applies to platform objects, scoping access through Entra ID and managed identity rather than broad shared credentials.
None of this is exotic. It is a decision to treat authorization as an architectural property of how data is accessed, not a check bolted onto each route after the fact, and then to verify it the way an attacker would.
The Takeaway
If you run a web application that holds other people's data, assume it has at least one broken object level authorization flaw until someone has tested for it properly with two accounts and systematic replay. It will not show up in a vulnerability scanner, it will not trip a firewall, and it will pass every test your team wrote from the inside, because to the server it looks like a legitimate user asking a legitimate question. The only reliable way to find it is to check ownership on every object reference and to test as though the identifiers are already known, because they usually are.
This is among the first things we look for in a secure code review or a penetration test, and it is the finding we report most often. It is also the cheapest to prevent, because a design that scopes every query to its owner never grows the bug in the first place.
