zenbyte.sh

Beyond the ID: Broken Access Control Past the Query String

Access control is the #1 category on the OWASP Top 10 — a spot it kept in the 2025 revision — and the reason isn't the bug everyone finds. Incrementing ?id=123 to ?id=124 is real, but it's the tip of the iceberg: those get found, deduped, and patched fastest. The valuable, durable access-control bugs live a layer deeper — in the checks nobody diffed and the endpoints nobody replayed. Here's the methodology I use to find them.

Authorization is a differential, not a value

The core mental model: an authorization bug isn't a bad value, it's a bad comparison. The server should decide "does this identity get to touch this object or function?" — and the bug is wherever that decision is missing, wrong, or skipped.

So the method is differential. Take one request and replay it across a matrix of identities you control:

Then diff the responses. If owner B's request returns owner A's data — or the unauthenticated request returns anything privileged — you have a lead. The discipline that keeps it clean: only ever use identities you own. Never a real user's. The bug is proven when your second account sees your first account's data.

That's the frame. The depth is in where you point it.

And it points two ways: at another identity's objects — broken object-level authorization, BOLA — or at another identity's functions and privileges — broken function-level authorization, BFLA. Both are OWASP API Security Top 10 entries; everything below is one axis or the other.

Where the real bugs hide

1. The other authorization idiom

Most applications enforce access control one of two ways: declarative (attribute / decorator / policy-based — [Authorize], @PreAuthorize, a permission_classes, a middleware) or imperative (a hand-rolled if user.id != obj.owner_id: deny inside the handler).

The trap: a codebase leans on one idiom by convention — and the bugs cluster in the other. If every controller carries a policy attribute, the endpoint someone wrote in a hurry with a manual check (or none) is the outlier that slips review. If everything is scoped imperatively through the ORM, the one route that trusts a framework default is the hole.

So enumerate both. Don't confirm "this app uses [Authorize]" and move on — find the endpoints that don't, and read the manual checks that do exist. Coverage across idioms is where the under-reviewed bug lives.

2. Present-but-wrong

A check existing is not a check being correct — the class scanners routinely miss and rushed reviewers wave past. Watch for:

The tell is always the same question: the check that's there — does it validate the right relationship, or just a relationship?

3. Bulk and batch endpoints

In my experience, the highest-yield, most-overlooked surface. GET /items/123 may be perfectly guarded — the team hardened it because it's the obvious IDOR target. But the same app's POST /items/bulk, GET /items?ids=1,2,3, a CSV export, or a GraphQL query taking ids: [...] frequently skips the per-object authorization the singular route enforces. The check was written once, for the singular endpoint, and never ported to the plural one.

Any endpoint operating on a collection — bulk actions, batch fetches, exports, search, GraphQL list resolvers — deserves the identity-matrix replay independently. The singular endpoint being safe tells you nothing about the plural one.

Confirming without false positives

A differential result is a lead, not a finding, until you've:

  1. Reproduced it with a clean working PoC across your two self-owned accounts — not a scanner hit, not a hunch.
  2. Confirmed the exposed data is genuinely owner A's (seed distinctive values in each account so there's no ambiguity).
  3. Ruled out benign explanations — shared/public objects, intended collaboration features.

If it doesn't survive that, it doesn't get written up. A false access-control report costs a program's trust, and yours.

The takeaway

The obvious ?id= IDOR is worth checking, but it's the commodity bug — found early, deduped often. The findings that hold up are structural: the authorization idiom the codebase didn't standardize on, the checks that exist but compare the wrong thing, and the bulk endpoints that never inherited the singular guard. Point the identity matrix at those, and validate ruthlessly.

— zen_byte