Dev.to Security 🔐 Cybersecurity 👁 0 📖 8 min read

BOLA/IDOR: The #1 API Vulnerability (and How to Test for It)

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 scanner

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:

  1. Authentication middleware validates the token and sets current_user.
  2. The route handler reads an identifier from the path.
  3. The handler queries the database by that identifier only.
  4. 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:

  1. At least two real identities (A and B) with their own objects.
  2. A mapping of which objects belong to whom.
  3. A rule for what "correct" looks like — usually 403 or 404 when A requests B's object, and 200 when 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:

  • PATCH lets an attacker modify another tenant's data,
  • DELETE lets them destroy it,
  • POST with 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/404 responses 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 if statements.
  • [ ] 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

Related guides

📰 Read the original article on Dev.to Security

Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.