BOLA (Broken Object-Level Authorization) - also known as IDOR - occurs when an API endpoint returns or mutates a resource based on an identifier in the request without verifying that the authenticated caller is authorized to access that specific object. Scanners can't find it because they don't know which objects belong to which user. Only a tester with two separate user accounts can verify whether Account A can access Account B's resources. We find it in over half of the APIs we test. Here's exactly how.
// 01 What BOLA is and why it's API1
An API endpoint that accepts an object identifier - a user ID, document ID, order ID, message thread ID - is implicitly making a promise: "I will only return or modify this object if the caller is authorized to interact with it." BOLA is what happens when that promise is broken: the authorization check is absent, performed after the data is already fetched, checked against the wrong identity context, or enforced only on some endpoints and not others.
OWASP placed it at API1 in their API Security Top 10 (2019 and 2023 editions) because it's the highest-frequency high-severity API vulnerability class. It appears consistently across REST APIs, GraphQL APIs, and gRPC services regardless of the language, framework, or cloud platform. It's also the hardest class to find with automation - which is why it persists at #1 while developers are improving injection and auth failure rates every year.
// 02 Why scanners are structurally incapable of finding BOLA
This isn't a limitation of scanner quality - it's structural. To detect BOLA, you need to answer the question: "Can User A access User B's resources?" That question requires:
- Two separate authenticated user accounts with distinct resource ownership
- Knowledge of which identifiers belong to which account - you need to know that document ID
3847belongs to User B, not User A - A semantic understanding of what "access" means for each endpoint - is a 200 response a successful access, or is it expected? Does a 403 mean the check passed, or is the endpoint failing silently with empty data?
- Coverage of every endpoint that accepts object identifiers - including nested resource paths, query parameters, request body fields, and response objects used as inputs to other calls
No automated scanner has two user accounts with relationship-mapped resource ownership built into it. Generic scanners that attempt BOLA detection by fuzzing IDs produce enormous false-positive rates because they don't know what a legitimate "not found" response looks like vs. an unauthorized access that returns 200.
"The scanner's score tells you about your known vulnerability database coverage. The pen tester's report tells you whether a competitor can read your customer's data. These are not the same question."
CyberFortify API security practice// 03 The three types of BOLA in production APIs
Type 1 - Sequential or predictable IDs
The simplest and most reliably exploitable pattern. Resources are identified by sequential integers or predictable short strings. User A creates a document and receives ID 4821. They can enumerate downward and access documents 4820, 4819, etc. - all owned by other users.
HTTP# User A is authenticated. User B created invoice 9183.
GET /api/v1/invoices/9183
Authorization: Bearer <user-A-token>
HTTP/1.1 200 OK
{
"id": 9183,
"owner_email": "[email protected]", # ← User B's data
"amount": 142500,
"line_items": [...]
}
# Severity: Critical if data is sensitive (PII, financial, PHI)
# CVSS: 8.1-9.8 depending on data classification
Type 2 - UUID-based IDs (still exploitable)
Many developers believe switching from sequential integers to UUIDs solves BOLA - it doesn't. UUIDs prevent guessing but not leakage. If a UUID appears in any server response, URL, email notification, webhook payload, or API log that User A can access, User A can use it to probe the API. The test is identical to Type 1 - the attack surface is narrower only if UUIDs are never exposed outside their authorized context.
HTTP# UUID leaked in a webhook notification payload to User A:
POST /hooks/user-a-endpoint
{ "event": "comment_added", "document_id": "f47ac10b-58cc-4372-a567-0e02b2c3d479" }
# User A uses that UUID directly against the documents endpoint:
GET /api/v2/documents/f47ac10b-58cc-4372-a567-0e02b2c3d479
Authorization: Bearer <user-A-token>
HTTP/1.1 200 OK # ← User B's document, fully accessible
Type 3 - Compound keys and nested resources
The most frequently missed pattern. Authorization is checked at the top level but not on nested resources. An API correctly verifies that User A can access /organizations/123, but doesn't re-verify on /organizations/123/members/456/private-notes - allowing horizontal access within a resource hierarchy that should be further restricted.
HTTP# Correct: User A is a member of org 123
GET /api/v3/organizations/123
Authorization: Bearer <user-A-token>
→ 200 OK (authorized)
# BOLA: member ID 456 belongs to User B, private notes are not shared
GET /api/v3/organizations/123/members/456/private-notes
Authorization: Bearer <user-A-token>
→ 200 OK ← authorization checked org membership only, not note ownership
// 04 The manual testing methodology
Our API BOLA testing methodology requires two accounts in the same application with distinct resource sets. We call these Account A (attacker) and Account B (victim). The test cycle for each resource type is:
Map every ID-bearing endpoint
Proxy all authenticated traffic for Account B through Burp. Extract every endpoint that accepts a resource identifier - in path, query string, or request body. Use Burp's Logger++ or a custom match-and-replace to tag them.
Replay with Account A token
For every captured request, swap the Authorization header to Account A's token and replay. Use Burp's "Match and Replace" rule or Autorize extension to automate token substitution across a full authenticated session.
Interpret responses carefully
A 200 response with Account B's data is a confirmed BOLA. A 403/401 is a pass. A 404 may mean the check passed (resource hidden) or the ID was wrong - probe neighbouring IDs to distinguish. A 200 with empty data may indicate a silent access-control failure.
Test write operations too
BOLA applies to mutations as well as reads. Test PUT, PATCH, DELETE on Account B's resources with Account A's token. Write-BOLA is often higher severity than read-BOLA because it allows data tampering or deletion across tenant boundaries.
Check admin-only endpoints
Test whether a regular Account A token can access admin-level endpoints by guessing or enumerating admin resource IDs. Vertical BOLA (user → admin) is typically Critical severity.
Document the ID source
For each BOLA finding, document where the victim ID was obtained - leaked in another response, guessable, enumerable. This determines exploitability and drives the CVSS score. Guessable IDs are always Critical; UUID-based IDs leaked through an ancillary channel are High.
// 05 GraphQL BOLA: a distinct challenge
GraphQL changes the BOLA test methodology significantly. Where REST has discrete endpoints per resource, GraphQL exposes a single endpoint with a queryable graph. Object-level authorization must be enforced at the resolver level - for every field, on every object type, in every query depth. Missing a single resolver allows BOLA through a deeply nested query.
GRAPHQL# Standard BOLA - User A queries User B's private document via direct ID
query {
document(id: "uuid-of-user-b-doc") {
title
content # sensitive field
owner { email }
}
}
# Nested resolver BOLA - authorization checked on 'project', not on 'invoices'
query {
project(id: "user-a-project-id") {
name
team {
members {
invoices { # ← resolver missing auth check
amount
lineItems { description }
}
}
}
}
}
# Batching BOLA - send multiple document IDs in one query
query {
doc1: document(id: "user-b-doc-1") { content }
doc2: document(id: "user-b-doc-2") { content }
doc3: document(id: "user-b-doc-3") { content }
}
For GraphQL APIs, our test methodology adds two extra steps: introspection mapping to identify all query and mutation fields that accept ID arguments, and depth-first traversal of nested relationships to check that authorization is re-evaluated at each resolver level, not just at the query root.
// 06 The one architectural fix that actually works
Band-aid fixes - adding WHERE id = ? AND user_id = current_user() to individual database queries - work, but they require every developer to remember to apply them on every endpoint. The pattern that actually eliminates BOLA at scale is context-bound object retrieval: the data access layer never accepts a bare object ID. It always requires an authorization context, and that context is injected from the authenticated request, not supplied by the caller.
TYPESCRIPT// ❌ Vulnerable pattern - bare ID lookup, authorization forgotten
async function getInvoice(invoiceId: string) {
return db.invoices.findById(invoiceId); // no ownership check
}
// ✅ Context-bound pattern - authorization is structurally enforced
async function getInvoice(invoiceId: string, ctx: AuthContext) {
const invoice = await db.invoices.findOne({
id: invoiceId,
tenantId: ctx.tenantId, // ← injected from JWT, not from request
// Optional: also check user-level ownership within tenant
...(ctx.role !== 'admin' && { ownerId: ctx.userId })
});
if (!invoice) throw new NotFoundError(); // same error for missing & unauthorized
return invoice;
}
// Key: NotFoundError instead of ForbiddenError prevents information leakage
// about which IDs exist outside the caller's authorization context
| Pattern | Prevents guessing | Prevents BOLA | Notes |
|---|---|---|---|
| Sequential integer IDs | No | No | Worst case - trivially enumerable |
| UUID v4 IDs | Yes | No | Still vulnerable if UUID leaks |
| Context-bound DB lookup | N/A | Yes | Correct fix - authorization structural |
| Object-capability references | Yes | Yes | Best practice - token IS the capability |