Skip to content
All posts
Threat Research9 min read

The authorisation bug you will not find with a scanner

Broken object-level authorisation is the finding we report most often, and almost none of it is caught by automated tooling. Here is how we test for it by hand, and the three patterns that keep producing it.

Tomas ErlendSenior Application Security Engineer

Every year we tally what we reported and what it was worth to the client. Broken object-level authorisation wins, and it is not close. It is the most common serious finding we raise, it is regularly the one that ends up in the executive summary, and it is almost never the one a scanner flagged first.

That is not a failure of any particular tool. It is structural. A scanner can tell that a request returned a 200, but it cannot tell you that the invoice in the response body belonged to a different company. Authorisation is the one class of bug where the vulnerability is entirely a question of business meaning, and business meaning is exactly what automation does not have.

Why tooling misses it

To find an authorisation flaw you need three things at once: a valid session, a valid identifier belonging to somebody else, and knowledge of whether the returned data should have been visible to the first party. Tools reliably have the first. They struggle with the second and they have no access at all to the third.

  • A scanner has no model of tenancy, so it cannot know that account 8134 and account 8135 belong to different customers.
  • Identifiers are frequently opaque, which means guessing valid ones requires a second authenticated context that the tool was never given.
  • A successful response looks identical whether the caller was entitled to it or not. There is no error to pattern match against.
  • Many of these endpoints are only reachable after several steps of application state, which crawlers do not reproduce faithfully.

The practical consequence is that a clean automated report tells you very little about the class of bug most likely to expose your customers to each other.

How we test for it by hand

The method is unglamorous and it works. We ask for two accounts in different tenants, and where roles exist, one account per role. Then we do the following.

  1. Exercise the application fully as account A, capturing every request. Not the happy path only: exports, bulk actions, webhook configuration, billing, invitations, file uploads and anything with an identifier in it.
  2. Extract every identifier that appears in a path, a query string, a body or a header. Include the ones that look derived rather than sequential.
  3. Replay each request with the session of account B, changing nothing else. Then replay with no session at all.
  4. Compare responses on content rather than status code. A 200 with an empty object is a pass. A 200 with somebody else's data is the finding.
  5. Repeat the exercise vertically: the same requests with a lower-privileged role inside the same tenant, which is where the interesting cases usually hide.

Three patterns that keep producing it

Across engagements the same three architectural habits account for most of what we find.

  • The check lives in the controller. Authorisation is enforced where the request arrives rather than where the data is fetched, so every new endpoint has to remember to repeat it. One will not.
  • Ownership is inferred from the request instead of the session. If the tenant identifier arrives in the payload, the payload can be edited, and the server is now trusting the caller to describe their own permissions.
  • The identifier was made unguessable and then treated as a secret. Opaque identifiers slow an attacker down for an afternoon. They leak through exports, shared links, referrer headers and support tooling, and none of that matters if the endpoint checks entitlement.

What to do about it this sprint

You do not need a rewrite. You need the check to move somewhere it cannot be forgotten, and you need a test that fails when somebody forgets it anyway.

  • Push the entitlement check into the data access layer, so fetching a record without a subject is not something the code can express.
  • Add a scoped query helper that always takes the acting principal, and make the unscoped version awkward enough that its use stands out in review.
  • Write one integration test per resource that asserts a second tenant receives a 404, and run it in continuous integration. It is the cheapest regression test in security.
  • Log authorisation denials with the subject and the object. If somebody starts walking your identifier space, that log is how you find out.

None of this is difficult work. It is simply work that nobody is prompted to do, because the tooling that most teams rely on to prompt them is structurally incapable of noticing the problem. Test it by hand, once, and you will know exactly where you stand.

Working on this

If this describes something you are dealing with, a scoping call costs nothing and takes about half an hour. We will tell you honestly whether it needs an engagement or an afternoon of your own team's time.

Request a Security Assessment

Next step

Turn a worry into a scoped engagement

Tell us what prompted you to read this. We will tell you what we would test first, and what it would cost.

Expires in

Limited time offer

We rebuilt your site for you. Claim it and we handle everything transfer, hosting, and your domain. Then update it anytime, just by asking AI.

Host for only$8 per monthBilled yearly
Claim limited offer now