Broken Object Level Authorization (BOLA) — also known as IDOR — is the most common
serious API flaw and the root cause of many of the largest data leaks of the last
decade. This guide explains how it happens, why scanners miss it, how to test for it
with an identity matrix, and how to fix it structurally.
1. What BOLA actually is
BOLA (Broken Object Level Authorization) is OWASP API Security API1:2023. The
older, web-era name is IDOR (Insecure Direct Object Reference). The idea is simple:
The API checks who you are, but not whether you are allowed to touch this specific
object.
A user authenticates correctly. The token is valid. The request is well-formed. The
only problem is that the object in the request belongs to someone else — and the API
returns it anyway.
GET /api/v1/invoices/1042 HTTP/1.1 Host: api.example.com Authorization: Bearer eyJhbGciOiJIUzI1NiIs... If 1042 belongs to another customer and the response is 200 OK with their invoice,
that is BOLA. No password was stolen. No encryption was broken. The attacker only
needed an account of their own and a for loop.
This is why BOLA is consistently the most frequently reported issue in API security
assessments and the mechanism behind a long list of breaches: Optus, T-Mobile,
Twitter/X, Peloton, USPS, and many smaller incidents that never made the news.
2. Why it keeps happening
BOLA is not a exotic bug. It is the natural result of a very common architecture:
- Authentication middleware validates the token and sets
current_user. - The route handler reads an identifier from the path.
- The handler queries the database by that identifier only.
- The object is serialized and returned.
# The classic mistake @app.get("/invoices/{invoice_id}") async def get_invoice(invoice_id: str, user: User = Depends(current_user)): invoice = await db.invoices.find_one({"_id": invoice_id}) return invoice The query is correct from a functional point of view. It is wrong from a security
point of view: it never asks whose invoice this is.
The fix is not "add one if." The fix is to make ownership part of the query itself:
# Ownership enforced in the data access layer @app.get("/invoices/{invoice_id}") async def get_invoice(invoice_id: str, user: User = Depends(current_user)): invoice = await db.invoices.find_one({ "_id": invoice_id, "tenant_id": user.tenant_id, # or owner_id }) if invoice is None: raise HTTPException(status_code=404) return invoice Returning 404 rather than 403 is intentional: it avoids confirming that an object
with that ID exists at all. Both are acceptable, but the choice should be consistent
across the API.
The deeper fix is a policy layer that every endpoint goes through, so a new
developer cannot accidentally write a query that skips the tenant filter. More on that
in section 6.
3. Variants you will meet in real APIs
BOLA is not limited to GET /resource/{id}. It appears in many shapes:
3.1. Identifier in the path
GET /api/users/8821/orders DELETE /api/documents/5510 The most obvious form. Also the easiest to test.
3.2. Identifier in the request body
POST /api/orders/details { "order_id": "ord_9931" } Scanners that only fuzz the URL miss this completely.
3.3. Identifier in a filter or search parameter
GET /api/reports?account_id=ACME-2&from=2026-01-01 The endpoint is "a report," but the account_id parameter selects whose data is
returned. If the server trusts the parameter instead of deriving the tenant from the
token, this is BOLA.
3.4. Nested and indirect references
GET /api/projects/12/tasks/44/attachments/9 Any of the three identifiers can belong to a different tenant. Test each level
independently — a common bug is checking the project but not the task.
3.5. Batch and bulk endpoints
POST /api/users/bulk { "ids": ["u1", "u2", "u3"] } Bulk operations often check authorization once, for the first item, or not at all.
Test with a mix of owned and foreign IDs in the same request.
3.6. GraphQL
query { invoice(id: "1042") { number total customer { email } } } GraphQL resolvers frequently fetch by ID without checking ownership. Field-level
authorization is a separate layer that is easy to forget.
3.7. File downloads and exports
GET /api/files/3f9a.../download GET /api/export?job_id=881 These are high-impact because they return raw data in bulk, and they are often
implemented outside the main authorization middleware.
3.8. "UUIDs are enough" is a myth
Random identifiers raise the cost of enumeration, but they do not fix BOLA. UUIDs leak
through other responses, URLs, logs, emails, and integrations. If the object is
reachable with a valid token and no ownership check, it is still broken — the attacker
just needs one leaked link.
4. Why ordinary scanners miss it
A DAST scanner sends requests and looks for signatures: SQL errors, reflected XSS,
stack traces, known CVEs. BOLA does not produce any of those. A foreign object returns
a perfectly normal 200 OK with perfectly valid JSON.
To detect BOLA, a test needs three things:
- At least two real identities (A and B) with their own objects.
- A mapping of which objects belong to whom.
- A rule for what "correct" looks like — usually
403or404when A requests B's object, and200when A requests their own.
That is differential testing, and it requires state, setup, and cleanup. It is also the
reason BOLA survives so long in production: it is invisible to tools that do not model
identity.
5. How to test for BOLA
5.1. Build the identity matrix
For each endpoint and method, test four perspectives:
| Perspective | Setup | Expected for A's object | Expected for B's object |
|---|---|---|---|
| Anonymous | No token | 401 | 401 |
| User A | A's token | 200 | 403 / 404 |
| User B | B's token | 403 / 404 | 200 |
| Admin | Admin token | 200 | 200 |
This table is the core of BOLA testing. Everything else is automation around it.
5.2. Prepare test data deliberately
Create two tenants (or two users) with:
- at least one object of every important type (invoice, document, order, project…),
- predictable, enumerable IDs where possible,
- a few "edge" objects: soft-deleted, archived, transferred, shared.
Soft-deleted and archived objects are a classic gap: the list endpoint hides them, but
the detail endpoint still returns them.
5.3. Test the whole CRUD surface
Do not stop at GET. BOLA on PATCH, PUT, and DELETE is often worse:
-
PATCHlets an attacker modify another tenant's data, -
DELETElets them destroy it, -
POSTwith a foreign parent ID lets them attach data to someone else's account.
5.4. Test derived and aggregate endpoints
GET /api/dashboard/summary GET /api/reports/monthly?account_id=... These often aggregate data and can leak counts, totals, or names even when individual
records are protected.
5.5. Automate it
A minimal test harness in pseudo-code:
for endpoint in endpoints: for method in endpoint.methods: own = call(method, endpoint, as_user="A", object=objects["A"]) foreign = call(method, endpoint, as_user="A", object=objects["B"]) anon = call(method, endpoint, as_user=None, object=objects["B"]) assert anon.status in (401, 403) assert own.status in (200, 201, 204) assert foreign.status in (403, 404), f"BOLA: {method} {endpoint}" Run it on every deploy. A new endpoint without an ownership check will fail
immediately, before it reaches production.
5.6. Watch for information leaks in errors
Compare the response for a non-existent object and a foreign object. If they differ
(e.g. 404 Object not found vs 403 Forbidden), an attacker can enumerate valid IDs.
Consistency matters as much as the status code itself.
6. How to fix it structurally
6.1. Enforce ownership in the query
The safest pattern is to never fetch by primary key alone. Scope every query by
tenant or owner:
invoice = await db.invoices.find_one({ "_id": invoice_id, "tenant_id": current_user.tenant_id, }) This makes BOLA impossible by construction for that query. It also protects against
future code paths that forget to call an authorization helper.
6.2. Centralize authorization
Put policy decisions in one layer — a service, a repository, or a policy engine — and
make every route go through it. Ideally:
- routes declare what they need (
can_read_invoice), - the policy layer decides whether the current identity has it,
- the data layer applies the tenant/owner filter.
Three separate concerns, one path. New endpoints inherit the protection instead of
re-implementing it.
6.3. Prefer opaque, scoped identifiers
UUIDs or prefixed IDs (Stripe-style inv_...) reduce accidental enumeration, but treat
this as defense in depth, not as the fix.
6.4. Test it like a product requirement
Add BOLA tests to the definition of done for every endpoint that accepts an object ID.
If the test matrix is part of CI, a missing check blocks the merge — which is exactly
what you want.
6.5. Monitor for enumeration
Even with correct authorization, watch for patterns:
- many
403/404responses from one identity in a short window, - sequential ID access,
- access to objects outside the caller's tenant,
- spikes in
GET /resource/{id}from a single token.
Detection gives you time to react when a bug slips through.
7. Where continuous testing fits
BOLA is the strongest argument for testing the running API, not just the code.
Static analysis can flag a query without a tenant filter, but it cannot tell you
whether the deployed endpoint actually returns another tenant's data. Only a request
with a real token and a real foreign object can answer that.
This is the layer LiveAPISec is built around: deterministic OWASP API Top 10 checks,
including differential BOLA/BFLA tests with A/B/anonymous identities, run on demand
or continuously, with results tracked between scans so you can see whether a
previously fixed BOLA has returned.
pipx install liveapisec liveapisec scan --help Whether you use a tool or your own harness, the principle is the same: every endpoint
that accepts an object ID needs an identity matrix test, and that test needs to run
again after the next deploy.
8. Checklist
Testing
- [ ] List every endpoint that accepts an object identifier (path, body, query, GraphQL).
- [ ] Create two tenants with objects of every important type.
- [ ] Test anonymous, owner, stranger, and admin perspectives.
- [ ] Test
GET,POST,PATCH,PUT,DELETE, and bulk endpoints. - [ ] Test nested resources at every level of the path.
- [ ] Test soft-deleted, archived, and transferred objects.
- [ ] Compare error responses for foreign vs non-existent objects.
- [ ] Test GraphQL resolvers and field-level access.
- [ ] Add the matrix test to CI as a blocking check.
Fixing
- [ ] Scope every query by tenant or owner in the data layer.
- [ ] Centralize policy decisions; do not rely on per-route
ifstatements. - [ ] Return 403 or 404 consistently, without leaking existence.
- [ ] Use opaque identifiers as defense in depth.
- [ ] Add enumeration monitoring and alerts.
- [ ] Re-run the matrix after every deploy.
Sources and further reading
- OWASP API Security Top 10 — API1:2023 Broken Object Level Authorization — https://owasp.org/API-Security/editions/2023/en/0xa1-broken-object-level-authorization/
- OWASP Web Security Testing Guide — Testing for IDOR — https://owasp.org/www-project-web-security-testing-guide/
- CWE-639: Authorization Bypass Through User-Controlled Key — https://cwe.mitre.org/data/definitions/639.html
- OWASP Authorization Cheat Sheet — https://cheatsheetseries.owasp.org/cheatsheets/Authorization_Cheat_Sheet.html
- Incident coverage: Optus, T-Mobile, Twitter/X, Peloton, USPS (industry press and regulator statements).

