Broken access control, explained
OWASP ranks broken access control first, found in 94 percent of tested apps. Why it is the hardest class to scan for, and the one design that prevents it.
- security
- vibe coding
- engineering
Broken access control is the number one entry in the OWASP Top 10, where OWASP reports that 94 percent of the applications they tested had some form of it. It is first not because it is exotic but because it is the failure with the widest definition: every rule about who may do what is an opportunity to not enforce the rule.
It is also the class that automated scanning is worst at, and understanding why is more useful than any individual example.
Why scanners struggle here
A scanner can find SQL injection because the bug is structural. There is a string, it has an interpolation, it reaches a query sink. That pattern is visible in the code with no knowledge of what the application is for.
Access control has no such pattern. The vulnerable code and the correct code are often the same code, and the difference is a fact that lives outside the file:
app.get("/api/invoices/:id", requireAuth, async (req, res) => {
const invoice = await db.invoice.findUnique({ where: { id: req.params.id } });
res.json(invoice);
});
Nothing here is syntactically wrong. There is even an auth middleware, which makes it look considered. Whether it is a vulnerability depends on whether invoices are meant to be private, which the code does not say and a static analyzer cannot know. This is the IDOR shape, and it is the most common single instance of the class.
That is why access control findings tend to come from a human, a test, or an attacker rather than from a scan, and why “the scan was clean” is a much weaker statement about this class than about any other.
The five shapes it takes
Missing check. The handler never asks who is calling. The endpoint was written to make a screen work and the screen only ever showed your own data, so nobody noticed the query does not filter.
Check on the wrong thing. The handler verifies the user is authenticated but not that they own the record. Authentication answers “who are you,” authorization answers “may you,” and conflating them is the single most common root cause here.
Check in the client only. The admin button is hidden for non-admins, and the
/api/admin/users route it would have called is open. Anyone who reads the
JavaScript bundle finds the route. Hiding UI is a usability decision that gets
mistaken for a security one.
Check that the user can influence. A role read from a JWT claim the client
can set, a tenant id taken from a request header, an isAdmin field accepted in
a request body and written straight to the user record. That last one is
mass assignment, and it turns a profile
update endpoint into a privilege escalation.
Check that is bypassed by another route. The REST handler is correct, and the GraphQL resolver, the export endpoint, the webhook replay, or the admin CLI that reaches the same table is not. Access control has to hold on every path to the data, and the number of paths grows quietly.
The design that prevents it
Scattered checks fail because they rely on every developer, and every model, remembering to write one. The durable fix is to make the unchecked version impossible to express, and there are two places to do that.
At the data layer. If the database itself filters by owner, a handler that forgets to filter returns nothing rather than everything. This is what row level security buys you on Postgres, and it is the strongest available answer because it holds even for code paths you have not written yet:
alter table public.invoices enable row level security;
create policy "owner reads own invoices"
on public.invoices for select
using (auth.uid() = user_id);
With that in place, the vulnerable Express handler above is no longer vulnerable, provided it connects as a role the policy applies to. The check moved somewhere it cannot be forgotten.
At the boundary, by default-deny. Where the data layer cannot help, make the framework refuse anything that has not opted in. Our own control plane composes every feature route inside one subtree behind an auth middleware, so a route added without thinking about auth is private rather than public. Making an endpoint public is an explicit, deliberate edit to a short list, which means the dangerous direction requires an action and the safe direction is free.
The general principle is the same in both cases: the secure state should be what happens when nobody does anything. Any design where security requires an addition will eventually meet a developer, or a code generator, in a hurry.
Writing the check when you have to write it
Resolve the scope from the session, and let the database enforce the join rather than fetching and then comparing:
app.get("/api/invoices/:id", requireAuth, async (req, res) => {
const invoice = await db.invoice.findFirst({
where: { id: req.params.id, userId: req.user.id },
});
if (!invoice) return res.status(404).end();
res.json(invoice);
});
Two details carry weight. The ownership predicate is in the query, not in an
if after the fetch, so there is no window where the object exists in memory
before the check and no chance of a later refactor dropping the comparison. And
the response is 404, not 403, because 403 confirms the record exists, which turns
the endpoint into an enumeration oracle for
account and resource discovery.
Testing it
This class is tested with two accounts, and there is no substitute:
- Create user A and user B, each with a record.
- Log in as A, capture the session token.
- Request B’s record id with A’s token.
- Repeat for every verb: read, update, delete, list, export.
# A's token, B's invoice id. The only acceptable answer is 404.
curl -s -o /dev/null -w '%{http_code}\n' \
-H "Authorization: Bearer $TOKEN_A" \
https://your-app.example/api/invoices/$INVOICE_ID_B
Then do it with no token at all, and then with A’s token against every admin route you can find in the bundle. Write these as tests rather than as a one-time audit, because access control regresses silently: nothing breaks for the legitimate user when a filter is dropped, so no bug report ever arrives.
That silence is the reason this class stays at number one. Every other vulnerability class eventually announces itself. A missing ownership check just serves data, correctly and quickly, to whoever asks, until the day someone notices they can change a number in a URL.