Methodology

Testing authorisation properly: the bugs scanners cannot find

Broken access control is consistently the most common serious finding in web applications and the least amenable to automation. A systematic way to cover it without testing every pair of everything.

Pental10 min read

Broken access control has sat at or near the top of the OWASP Top Ten for years, and it is the category least served by automation. A scanner can tell you a header is missing. It cannot tell you that a project manager at one company can read another company's invoices by changing a number, because it does not know what a company is.

That makes authorisation the part of a web application test where a human being earns their fee, and it is worth doing systematically rather than opportunistically.

Build the matrix before you test

Two artefacts, sketched at scoping and finished on day one.

Roles. Every distinct role, including the ones nobody mentions: support staff, read-only auditors, API service accounts, the deactivated user, the invited-but-not-yet-accepted user, the user whose subscription lapsed.

Objects. Every type of thing the application owns, and who is supposed to read, modify, create and delete each one.

The matrix is roles against objects against verbs. You will not test every cell and you should not pretend otherwise. You will test the cells where a wrong answer matters, and at the end you will be able to say precisely which those were, which is more than most reports manage.

Two roles is one boundary. Five roles is ten. If the client adds a role during the engagement, that is a scope change, and it is much easier to say so on day one than in the closing meeting.

Test the two failures separately

Horizontal: same role, someone else's data

Two accounts at the same privilege level in different tenants or organisations. Take a request from one, replay it with the other's session, and change nothing else. Then hunt the identifier everywhere it hides:

  • In the path, and in the query string.
  • In a JSON body, including nested objects and arrays.
  • In a GraphQL variable, and in a batched query alongside a legitimate one.
  • In a filename, an export parameter, a print or PDF route.
  • In a WebSocket frame after the connection is established.
  • In a header the application put there itself, which is where the interesting ones live.

This is where the serious findings usually are, and it is the class of bug most likely to be reachable by an ordinary customer rather than an attacker.

Vertical: lower role, higher function

Take an administrative action, capture it, and replay it as the least privileged account you hold. Then again with no session at all. Then check whether the endpoint accepts a method it should not, because authorisation applied to POST and forgotten on PUT is a genuinely common pattern rather than a theoretical one.

Where checks go missing

PlaceWhy it happens
Second and later steps of a flowStep one is authorised and the rest inherit trust from a session variable set there.
Exports and reportsThe interface filters by tenant; the CSV export runs its own query, written by someone else, months later.
Bulk endpointsA check written for a single identifier is applied to an array and validates the first element only.
Sort, filter and search parametersAnything reaching a query builder can reach rows it should not, without any injection involved.
File and attachment routesServed from a predictable path, or from a storage bucket that is public with an unguessable name.
Cached responsesA shared cache keyed without the tenant returns one customer's data to another, with no vulnerable code anywhere in the request path.
Recently shipped featuresThe pattern established in year one was not applied to the endpoint shipped last month, because the person who established it has left.

Where the check lives is the finding

An application that enforces authorisation in the interface, in middleware, and in the database survives one of those being wrong. An application enforcing it in exactly one place, in application code, alongside every other concern, will eventually ship an endpoint that forgot, and the only question is which one.

Report the instance you found, then report the reason there will be another one.

When you write it up, say where enforcement happens today and what it would take to move it somewhere a future endpoint cannot bypass. That recommendation is worth considerably more than the identifier you changed, and it is the difference between a finding and advice.

Evidence that survives a dispute

Access-control findings are disputed more often than any other class, and the dispute is almost always the same sentence: "that account did have permission". Capture the pair that ends it.

  • The full request as sent, including headers.
  • Which account the session or token belongs to, and its role.
  • The response, with enough of the returned data to prove ownership and no more.
  • The same request from the legitimate account, for comparison.
  • A note of what you did not do, such as modifying or deleting the object you could reach.

Capture the minimum that proves ownership. One record, with the identifying field visible and the rest cropped, is stronger evidence than a screenshot of a hundred rows and a considerably smaller problem for you to store.


Pental is built by the people writing this

Engagement management for testing firms, on a database you own, under your brand, with the AI running on your key.