Web security

Broken access control: the bug class that outlives every framework

In one paragraph

Broken access control persists because frameworks provide authentication out of the box but leave authorisation as per-endpoint code a developer must remember to write. The structural fix is to scope every data query to the authenticated principal by default, so an endpoint must actively opt out of authorisation rather than opt in.

Every framework ships authentication. Sessions, password hashing, token issuance, middleware to reject anonymous requests — solved, documented, hard to get wrong. Then authorisation is left to you, expressed as code you write once per endpoint and must remember to write every time.

That asymmetry is the whole explanation for why broken access control has stayed at the top of the OWASP list while other categories have moved down. It is not that developers do not understand it. It is that the default is wrong.

What it looks like in practice

  • Horizontal: user A reads user B’s invoice by changing an identifier. The endpoint checks you are logged in, never that the record is yours.
  • Vertical: a support role reaches an admin-only endpoint because the check lives in the interface that renders the menu.
  • Tenant: in a multi-tenant product, a query filters by user but not by organisation, and one customer sees another’s documents.
  • Function: a mutation endpoint has no check at all, because the only way to reach it was a button the interface hides.

The last one is the one teams underestimate. Hiding a button is not access control. A request is a request, whatever produced it.

Why per-endpoint checks fail at scale

A codebase adds endpoints continuously. Each one needs an ownership check that is easy to write and invisible when missing — nothing breaks, no test fails, the feature works perfectly for the developer testing with their own account. The failure only appears when someone uses a second account, which is exactly what nobody does during feature work.

So coverage decays. A team that had authorisation right at launch will, twelve months later, have five endpoints that missed it, and no way to know which five without testing all of them.

The structural fix: make scoping the default

Move the check from the endpoint to the data layer. Every query starts scoped to the authenticated principal, and reaching outside that scope requires explicit, visible, reviewable code.

// Instead of remembering this in every controller:
$order = Order::findOrFail($id);
abort_if($order->user_id !== $request->user()->id, 403);

// Make the scope the default, so forgetting it fails closed:
$order = $request->user()->orders()->findOrFail($id);   // 404, not 403

// Or a global scope on the model, with an explicit escape hatch
// that shows up in code review:
Order::withoutTenantScope()->find($id);   // has to be justified

Return 404 rather than 403 for records outside the scope. A 403 confirms the record exists, which is a small information leak that helps an attacker enumerate.

Make the gap testable

One test helper turns this from a review burden into an automated check: given a resource, assert that a second account gets 404 on every method.

  • Write the helper once. Use it in the template for new resources.
  • For multi-tenant products, seed two organisations in the test suite permanently — the bugs live in the boundary between them.
  • Add a test that enumerates registered routes and fails when a new route has no corresponding authorisation test. Blunt, and it works.

The API matters more than the app

Most of these findings today are on the API, not on server-rendered pages, because the client is a mobile app or a single-page application that enforces nothing. Test the endpoints directly, with two accounts, ignoring the client entirely — that is how an attacker reaches them and how a test should.

What good looks like

  1. Queries are scoped by default at the data layer, per user and per tenant.
  2. Escaping the scope is a named, greppable call that appears in review.
  3. Every resource has a cross-account test, generated from the template rather than remembered.
  4. Manual testing with two accounts happens on a schedule, because the structural fix can still be bypassed by a clever join.

Do those four and this bug class stops being a finding in every report. Skip them, and no amount of scanning will help — no scanner knows who is supposed to see what.

Want this tested properly?

Scope and a fixed quote within two working days. First consultation is free.