BOLA, BFLA, BOPLA: the three authorisation bugs no scanner will find for you
Optus lost roughly 10 million customer records to an API with sequential IDs and no authorisation check. Here is how to tell the three authorisation failures apart, how to test each one, and why automated tools are structurally blind to them.
7 min read0 views
Run any commercial scanner against a modern API and it will find missing headers, an outdated TLS suite, maybe some reflected input. It will not find the bug that actually takes the company down, because the bug that takes the company down looks like a completely normal, successful, well-formed request.
Broken Object Level Authorization is the number one entry in the OWASP API Security Top 10 for a reason. It is present in a very large share of real API compromises, and it produces 200 OK every time. A scanner sees a valid request and a valid response. Only a human, or a test that knows who should be allowed to see what, can tell that the response belonged to somebody else.
There are three of these, they are routinely conflated, and the distinction changes what you test and what you fix.
The three, precisely
BOLA, Broken Object Level Authorization (API1:2023). The wrong object.
GET /api/v1/invoices/1043
Authorization: Bearer <valid token for user A>
The token is valid. The endpoint is documented. Invoice 1043 belongs to user B. The server checks that you are authenticated and forgets to check that this object is yours. Historically called IDOR.
BFLA, Broken Function Level Authorization (API5:2023). The wrong verb or route.
DELETE /api/v1/users/9821
POST /api/v1/admin/reindex
You may legitimately GET a user. Nobody checked whether you may DELETE one. Or an administrative route is protected only by not being linked in the UI. The object is fine; the operation is not yours.
BOPLA, Broken Object Property Level Authorization (API3:2023). The wrong field.
Two directions, and both matter:
- Excessive data exposure. You request your own profile and the response includes
password_reset_token,internal_risk_score,ssn, or the full object the ORM serialised because someone wrotereturn jsonify(user.__dict__). - Mass assignment. You
PATCHyour own profile with{"display_name": "x", "role": "admin"}and the server applies every key it received.
BOPLA is the one people most often miss, because the request looks entirely legitimate. You are allowed to read your profile. You are allowed to update it. The failure is at field granularity, one level below where most authorisation code operates.
What this costs: Optus
In September 2022, Optus. One of Australia's largest telecommunications providers, disclosed a breach affecting roughly 10 million customers.
The mechanics were not sophisticated. A public-facing API endpoint served customer records without requiring authentication, and the customer identifiers were sequential. Reported analyses put the endpoint's exposure at months, and trace the underlying access-control flaw back years before the breach.
Sequential identifiers plus no authorisation check equals a for loop. Not a zero-day, not a chain, not a nation-state: a for loop.
Two things are worth extracting.
The enumeration multiplier. Sequential IDs do not create the vulnerability. The missing check does, but they decide whether an attacker gets one record or all of them. UUIDs are not an authorisation control and must never be described as one, but they do change the economics: with a UUIDv4 you must find each object before you can steal it. Defence in depth means both: authorise every object access, and do not hand out a countable namespace.
"Internal" is not an authorisation state. Endpoints reach production believing they are protected by obscurity. An undocumented path, a mobile-app-only route, a service assumed to sit behind a gateway. Every one of those assumptions is a network topology detail that a reorganisation, a cloud migration or a misapplied ingress rule can quietly invalidate.
Testing it properly
There is one method, and it needs preparation that most engagements skip: two accounts of every role.
Two low-privilege users (A and B), one privileged user, and an unauthenticated session. Without account B you cannot distinguish "the object does not exist" from "the object exists and you may not see it", and that distinction is the entire test.
Then, systematically:
- As A, enumerate your own object IDs. Walk the whole application capturing every identifier that appears in a URL, body, header or JSON response.
- As B, replay every one of A's requests. Same request, B's token. Anything other than
403/404on A's objects is BOLA. Automate the replay, but read the results yourself. - Replay with no token at all. This is the Optus case and it is quick to check.
- For each route, try every verb.
GET,POST,PUT,PATCH,DELETE, andOPTIONSto learn what the server admits to supporting. That is BFLA. - Diff the responses. Compare the JSON the API returns against the fields the UI displays. Every extra field is potential excessive data exposure. This is where you find the tokens.
- Add fields to write requests. Send
role,is_admin,verified,balance,user_id,tenant_id,priceinPATCHbodies and see which stick. That is mass assignment. - Do not forget object references that are not IDs. Filenames, S3 keys, export handles, report identifiers, WebSocket subscription topics and GraphQL node IDs are all object references.
Where they hide, in rough order of hit rate: bulk and export endpoints, /v1 routes left behind by /v2, mobile-only endpoints, webhook receivers, admin panels that filter in the front end, nested routes (/orgs/{a}/projects/{b}/files/{c}. One of those three is often unchecked), and anything with internal in the path.
That nested-route case deserves emphasis. Developers commonly check the outermost object and assume the nesting implies the rest. If file/{c} is looked up by its own primary key rather than scoped to project {b}, then changing {c} alone crosses tenants while {a} and {b} still say you belong.
Fixing it
The check belongs in the data access layer, not the controller. Every controller-level check is one a developer can forget on the next endpoint, and endpoints are added weekly. The durable version is that fetching an object requires the actor:
# Fragile: authorisation is a separate step someone can omit
invoice = Invoice.get(invoice_id)
if invoice.owner_id != current_user.id:
abort(403)
# Durable: there is no way to fetch an object you do not own
invoice = Invoice.for_user(current_user).get(invoice_id)
The second form makes the secure path the only path. A developer who forgets to think about authorisation still gets it, because the unscoped query does not exist in the codebase.
Serialise explicitly, never reflectively. Define the response shape as a schema listing the fields that may leave the system. Never return an ORM object, __dict__, SELECT *, or a toJSON() that walks every column. This kills excessive data exposure by construction. A new column added next quarter is not silently published.
Allowlist writable fields. Same rule from the other direction, and the same rule that stops class pollution: bind incoming JSON to a schema of permitted fields rather than applying the body onto a model.
Make it testable. The strongest control here is an integration test per endpoint asserting that user B receives 404 for user A's object. It is tedious to write once and it catches the regression forever, which matters, because BOLA is overwhelmingly reintroduced by new endpoints rather than left in old ones.
Return 404, not 403. 403 confirms the object exists, which hands an attacker an enumeration oracle for free.
Detection
You can see this in logs, which is more than can be said for most application bugs:
- One authenticated principal requesting a high count of distinct object IDs on a single endpoint in a short window. This is the single highest-value API detection rule, and it is a
GROUP BYaway in any log store you already have. - Sequential ID walks, successive requests differing by one.
- A spike in
403/404on object routes from one token: that is someone testing. - Requests to routes that no released client version calls.
- Successful
PATCH/PUTbodies containing fields your schema does not accept, log the rejected keys rather than dropping them silently.
Take this away
Authentication answers who are you. These three bugs are all the same failure to answer the next three questions:
- Which object may you touch? (BOLA)
- Which operation may you perform? (BFLA)
- Which fields may you read and write? (BOPLA)
A scanner can check the first question's existence. It cannot check any of the three answers, because every one of them depends on business context it does not have. That is why this remains the top API risk, and why it stays a human job.
Further reading
Was this useful?
Comments
Loading comments…