Web security
Broken access control: still the number-one web risk
Why authorisation flaws keep topping the charts, how they hide inside ordinary features, and a simple way to test your own application for them.
12 Aug 2026 · Web security
Of every category of web vulnerability we report, broken access control is the one we find most often and the one that causes the most damage when it is missed. It has sat at the top of the OWASP Top Ten since 2021, and nothing in our own engagement data suggests that is about to change.
The reason is structural. Most other vulnerability classes are failures of a library, a parser or a configuration — things a scanner can recognise. Access control is a failure of business logic. The application does exactly what it was written to do; it was simply written to trust the wrong thing.
What it actually looks like
The textbook example is an identifier in a URL. An invoice lives at /invoices/4821, the user changes it to /invoices/4822, and someone else's invoice appears. This is usually called IDOR — insecure direct object reference — and it is still remarkably common, particularly in APIs that were built for a single-tenant product and later extended to serve many customers.
But the interesting cases are rarely that obvious. The ones we see most often in production applications look like this:
- Function-level gaps. The UI hides the "export all users" button from anyone who is not an administrator, but the endpoint behind it only checks that you are logged in.
- Role checks in the wrong place. Authorisation is enforced in the controller that renders a page, but a second controller — an export, a webhook, a mobile endpoint — reaches the same data by a different route and was never given the check.
- Tenant boundaries that leak on secondary objects. The main record is scoped to your organisation correctly, but the attachments, comments or audit entries hanging off it are fetched by their own ID with no tenancy filter.
- Workflow state that can be skipped. An approval step is enforced by the sequence of screens rather than by the server, so posting directly to the final step bypasses it.
- Privileges that survive a change. A user is downgraded from admin to member, but their existing session, API token or cached permission set keeps the old rights until it expires.
Why scanners miss it
An automated scanner has no idea that invoice 4822 belongs to a different company than invoice 4821. It sees two valid HTTP 200 responses. Detecting the flaw requires knowing what the application is supposed to permit, which means knowing the roles, the tenancy model and the business rules. That knowledge comes from a human reading the application, not from a signature database.
This is the practical difference between a vulnerability scan and a penetration test, and it is why we test authenticated applications with several accounts at different privilege levels rather than one.
How to test your own application
You do not need specialist tooling to find a good share of these. You need two accounts and some discipline.
- Create two users in different organisations, and two more in the same organisation at different privilege levels — say an administrator and a read-only member.
- Working as the higher-privileged user, walk through the application and record every request the browser makes. Your browser's network tab is enough to start.
- Replay each of those requests with the lower-privileged user's session, changing nothing else. Then replay them with the other organisation's session.
- Any request that returns data rather than a clear denial is a finding. Pay particular attention to anything that returns HTTP 200 with an empty body or a redirect — partial enforcement is still a gap.
- Repeat for the object identifiers: increment them, and try identifiers you know belong to the other tenant.
Do this for the endpoints that matter most first: anything that exports, anything that lists, anything that touches billing, and anything added in the last two releases. New features are where enforcement is most often forgotten.
Fixing it properly
The durable fix is not more checks scattered through controllers — it is moving the decision to one place. Deny by default, then authorise centrally: a single layer that every data access passes through, which takes the acting user and the requested object and answers yes or no. Frameworks differ in how they express this — policy objects, row-level security in the database, a middleware that scopes every query by tenant — but the principle is the same. If a developer has to remember to add a check, eventually one will not.
Two supporting habits make a large difference. First, make identifiers unguessable — not as a security control on its own, but because it removes the trivial enumeration path. Second, log authorisation denials and alert on bursts of them; a user generating hundreds of denied requests is telling you something useful.
If you take one thing away: test your application with a second, less-privileged account. Most of the access control findings we report would have been caught by someone doing exactly that before release.
Next step
Want this checked on your own systems?
We scope the work with you in writing before any testing starts.