BOLA testing
Broken object level authorization is the most damaging flaw class in production APIs, and the one scanners are structurally blind to. This page explains what BOLA is, how it is exploited, why automated tools miss it, and how Operator proves cross-account access across every object-scoped operation with reproducible evidence.
Written by Berk Dusunur, Founder & CEO, Planck Proof · Updated September 2026
What BOLA is
Broken object level authorization, listed as API1:2023 in the OWASP API Security Top 10 and mapped to CWE-639, occurs when an API confirms that a caller is authenticated but never confirms that the specific object the caller references belongs to them. The server answers the question are you logged in, but skips the question is this yours.
Authentication without authorization
The token is valid, so the request is served. The object identifier in the path or body is never checked against the identity that presented the token, so any authenticated user can address any object.
The same flaw, API framing
BOLA is the API-specific name for what web testers long called an insecure direct object reference, or IDOR. Same root cause, same fix, described in the vocabulary of endpoints and objects. See BOLA vs IDOR for the full comparison.
Common, simple, high impact
Object identifiers travel in nearly every request an API serves, the exploit is a single substituted value, and the payoff is another user or tenant data. That combination puts it at the top of the list.
How to test for BOLA, step by step
Manual API BOLA testing does not require exotic tooling, only two authenticated identities and the discipline to swap every identifier an API accepts. Those identifiers take many shapes: a sequential integer walked up or down, a UUID harvested from another response or a log line, an identifier buried in a JSON body, or a value resolved through a nested field. The steps below build the matrix and run the swap methodically, across every operation instead of a sample, and expand on the exploitation vectors and impact described above.
Enumerate every object-scoped operation
Work from the OpenAPI or GraphQL schema, not a crawl. List every operation that accepts an object identifier in the path, the query string, or the request body, including bulk and export endpoints that take a list of IDs.
Provision two accounts per role
At minimum, create two peer accounts in the same role so you can test horizontal access between equals, plus one account in each other role so vertical access between privilege levels is covered too.
Record an authenticated baseline
Call each operation as its rightful owner first and save the response. You need to know what a legitimate 200 looks like, and which account the object actually belongs to, before a swap can prove anything.
Swap the identifier, keep the token
Keep account A authenticated, and substitute account B's identifier from the baseline into the path, query, or body. A response that matches account B's baseline, returned to account A's token, is the finding.
Repeat vertically across roles
Replay a lower-privileged account's session against objects that belong to a higher-privileged one, and check whether privileged roles are scoped to a single tenant or can reach every tenant's objects by accident.
Test every verb on the same object
A GET might correctly return 403 while PATCH, PUT, or DELETE on the identical identifier does not. Authorization is frequently checked on the read path and forgotten on the write and delete paths, so test all of them.
Follow nested and expanded references
Relations reached through an include or expand parameter, or through an embedded link in a response, need the same ownership check as the top-level object. Many implementations apply the check only to the primary resource.
Capture evidence, then retest
Save the request and response for both identities plus the baseline that proves ownership, so the finding is reproducible. After a fix ships, retest the same operation and its neighbors, not just the one you reported.
BOLA across HTTP methods: GET, PATCH, DELETE
Object-level authorization has to hold on every verb an operation exposes, not only the one a team remembers to protect. The three illustrations below use the same two accounts and the same object type, a support ticket, swapped across a read, a write, and a delete, to show how one missing check produces a 200 where a 403 belongs. Each is followed by a copy-pasteable curl command so you can adapt it against your own API.
GET: reading another account's object
- Authenticated as account A and called
GET /api/v1/tickets/9142, account A's own ticket, a correct 200 OK. - Authenticated separately as account B, opened a ticket, and confirmed its ID, 9143, loads correctly with B's own token.
- Replayed
GET /api/v1/tickets/9143using account A's token in place of B's. - The API returned
200 OKwith account B's ticket body, subject, messages, and contact email, to account A.
GET /api/v1/tickets/9143 HTTP/1.1
Host: api.example.com
Authorization: Bearer <ACCOUNT_A_TOKEN>
HTTP/1.1 200 OK
Content-Type: application/json
{ "ticket_id": 9143, "account_id": "acct_b", "subject": "Billing dispute", "messages": ["..."], "contact_email": "userb@example.com" }
curl -s https://api.example.com/api/v1/tickets/9143 \ -H "Authorization: Bearer <ACCOUNT_A_TOKEN>"
Illustrative example, not a specific customer result. The expected response is 403 Forbidden; the object identifier alone should never be sufficient to reach it.
PATCH: modifying another account's object
- Authenticated as account A and called
PATCH /api/v1/tickets/9120against account A's own ticket, a correct 200 OK. - Replayed an identical request body against account B's ticket ID, 9143, with account A's token still attached.
- The API returned
200 OKand closed account B's ticket, overwriting its status and subject without B ever being asked. - Repeated against a second, unrelated ticket ID to confirm the missing check was not a one-off.
PATCH /api/v1/tickets/9143 HTTP/1.1
Host: api.example.com
Authorization: Bearer <ACCOUNT_A_TOKEN>
Content-Type: application/json
{ "status": "closed", "subject": "Resolved" }
HTTP/1.1 200 OK
Content-Type: application/json
{ "ticket_id": 9143, "account_id": "acct_b", "status": "closed", "subject": "Resolved" }
curl -s -X PATCH https://api.example.com/api/v1/tickets/9143 \
-H "Authorization: Bearer <ACCOUNT_A_TOKEN>" \
-H "Content-Type: application/json" \
-d '{"status":"closed","subject":"Resolved"}'
Illustrative example, not a specific customer result. Write operations need the exact same ownership check a read needs; here it was missing on the write path even though the read path might have been fine.
DELETE: destroying another account's object
- Authenticated as account A and called
DELETE /api/v1/tickets/9110against account A's own resolved ticket, a correct 200 OK. - Replayed
DELETE /api/v1/tickets/9143, account B's open ticket, with account A's token. - The API returned
200 OKand removed the ticket. Account B's subsequentGETon the same ID returned 404, confirming the object was gone, not merely hidden from A.
DELETE /api/v1/tickets/9143 HTTP/1.1
Host: api.example.com
Authorization: Bearer <ACCOUNT_A_TOKEN>
HTTP/1.1 200 OK
Content-Type: application/json
{ "ticket_id": 9143, "deleted": true }
curl -s -X DELETE https://api.example.com/api/v1/tickets/9143 \ -H "Authorization: Bearer <ACCOUNT_A_TOKEN>"
Illustrative example, not a specific customer result. DELETE is the highest-consequence verb to leave unchecked: the result is not disclosure but permanent, unrecoverable loss of another account's data.
BOLA in GraphQL
A single GraphQL endpoint hides the same object-level gap behind a different surface. Three patterns matter most. The global node(id: ...) resolver that many schemas expose will return whatever type the decoded ID points to, so a caller who can decode or guess another account's opaque ID can request it directly, no separate endpoint required. Aliased batching lets a caller pack dozens of ID lookups into a single request and response, which both accelerates enumeration and slides under a rate limit tuned for one query per call. And an unrestricted introspection query hands an attacker the full schema, every type, field, and argument, turning ID guessing from blind trial into a targeted search once the shape of a type like SupportTicket is known. For the full walkthrough of how base64 Global IDs decode to sequential integers, with three disclosed proofs, see BOLA in GraphQL.
- Ran an introspection query to confirm a
SupportTickettype existed and thatnode(id: ID!)could resolve it. - Decoded account A's own ticket ID from a prior response to learn the underlying identifier format, then re-encoded the same pattern with account B's known ticket number.
- Sent the query below with account A's session token. The server resolved the node without checking whether the ticket belonged to the caller.
POST /graphql HTTP/1.1
Host: api.example.com
Authorization: Bearer <ACCOUNT_A_TOKEN>
Content-Type: application/json
{ "query": "{ node(id: \"U3VwcG9ydFRpY2tldDo5MTQz\") { ... on SupportTicket { id subject messages } } }" }
HTTP/1.1 200 OK
Content-Type: application/json
{ "data": { "node": { "id": "U3VwcG9ydFRpY2tldDo5MTQz", "subject": "Billing dispute", "messages": ["..."] } } }
curl -s -X POST https://api.example.com/graphql \
-H "Authorization: Bearer <ACCOUNT_A_TOKEN>" \
-H "Content-Type: application/json" \
-d '{"query":"{ node(id: \"U3VwcG9ydFRpY2tldDo5MTQz\") { ... on SupportTicket { id subject messages } } }"}'
Illustrative example, not a specific customer result. The same schema also allowed the identical query aliased dozens of times in one request, turning single-object BOLA into bulk enumeration in one round trip.
An authorization matrix template
Before you swap a single identifier, write down who should reach what. List every object type your API exposes as a row and every role as a column, then mark what each role should be allowed to do to an object it does and does not own. Testing is simply confirming the API agrees with every cell.
| Object | Guest | User | Admin |
|---|---|---|---|
| Own profile | Deny | Read, update | Read, update |
| Another user's profile | Deny | Deny | Read only |
| Own support ticket | Deny | Read, update, delete | Read, update, delete |
| Another user's ticket, same tenant | Deny | Deny | Read, update |
| Ticket in a different tenant | Deny | Deny | Deny, unless explicitly cross-tenant |
| Own invoices and payment methods | Deny | Read only | Read, update |
| Another account's invoices | Deny | Deny | Deny, unless billing admin |
| User list, all accounts | Deny | Deny | Read only |
| Admin settings and roles | Deny | Deny | Read, update |
Fill this in for your own object types before testing starts, then treat every Deny cell as a request you must send and expect a 403 or 404 back, and every Allow cell as a request you must send and expect the data. A finding is any cell where the API's actual behavior does not match what you wrote down.
Why scanners and DAST miss it
Detecting BOLA is not a payload problem, it is an authorization problem, and that is exactly what automated tooling cannot model.
A 200 looks like success
A scanner sees a valid response for a valid identifier and marks it healthy. It cannot know the identifier belongs to a different account, so a data breach is indistinguishable from correct behavior; there is no error, no anomaly, nothing a signature can match.
Detection needs a comparison
Proving BOLA requires at least two authenticated identities and a comparison of what each is permitted to reach. A fixed DAST script runs as a single session against a single object graph, so the cross-account comparison that reveals the flaw never happens.
This is why BOLA is a defining case for API penetration testing that reasons about identity rather than matching signatures, and a recurring theme in our writeup on BOLA versus BFLA authorization flaws.
How Operator proves BOLA
You provide one bearer token per role or tenant. Operator reads the specification, enumerates every operation that accepts an object identifier, and builds the same kind of authorization matrix described above. It then replays one identity's requests as another across that entire matrix, on every verb, and compares the responses against what the matrix says should happen.
Two accounts in the same role is often the strongest signal, because it proves horizontal access between peers rather than only vertical access between privilege levels. Because the agent works from the spec rather than a crawl, coverage is every object-scoped operation, not a sampled subset, and it reruns continuously so a BOLA introduced by a new deploy is caught the week it ships. Function-level checks are covered the same way; see API authorization testing for the full picture.
- One token per role is all the access the test requires.
- Every object-scoped operation enumerated from the spec, not sampled.
- Cross-account replay of account A requests using account B identifiers, on every verb.
- Horizontal and vertical access both exercised across the matrix.
- Continuous reruns so drift-introduced BOLA surfaces quickly.
What a proven finding looks like
Every BOLA finding Operator reports carries the same kind of evidence as the illustrations above: the cross-account request, the response that confirms it, and the baseline proving the object belonged to someone else.
- Authenticated as account A and requested
GET /v2/invoices/8842with account A's own token. - The baseline, captured with account B's token, showed invoice 8842 belongs to account B.
- The API returned
200 OKwith account B's invoice, amount, and last four card digits, to account A.
GET /v2/invoices/8842 HTTP/1.1
Host: api.example.com
Authorization: Bearer <ACCOUNT_A_TOKEN>
HTTP/1.1 200 OK
Content-Type: application/json
{ "id": 8842, "account_id": "acct_b", "amount": 4120.00, "card_last4": "4242", "billing_email": "userb@example.com" }
curl -s https://api.example.com/v2/invoices/8842 \ -H "Authorization: Bearer <ACCOUNT_A_TOKEN>"
Illustrative example, not a specific customer result. The finding ships with both request and response captures for both identities, so the cross-account access is reproducible and the fix is verifiable on retest.
How to fix and prevent BOLA
The rule is simple to state and easy to omit: enforce object-level authorization on the server for every request that references an object, and never trust a client-supplied identifier as proof of ownership. Authorization must be checked against the authenticated identity at the point of data access, not assumed from an unguessable value.
- Check ownership server-side on every object read, write, update, and delete.
- Scope queries to the caller so the data layer cannot return foreign objects.
- Do not rely on unguessable IDs as an access control; obscurity is not authorization.
- Deny by default and grant access only when ownership or a role permits it.
- Centralize the check so every new endpoint inherits it rather than reimplementing it.
Go deeper on API vulnerabilities
Tenant isolation testing
Prove whether one tenant can reach another tenant's objects in a multi-tenant API, and how Operator confirms it with evidence.
Read more → AuthorizationAPI authorization testing
BOLA, BFLA, and broken function-level access across every user role, and why scanners miss it.
Read more → ReferenceOWASP API Security Top 10
All ten 2023 categories explained, from broken object level authorization to unsafe consumption of APIs.
Read more → MethodologyHow to pentest an API
A practical, step-by-step API penetration testing methodology, from spec to proof.
Read more → Agentic AIMCP server security testing
Security testing for Model Context Protocol servers, tools, and the APIs behind AI agents.
Read more → API5:2023BFLA testing
How broken function level authorization lets a normal user reach privileged functions, and how we test it.
Read more →Common questions about BOLA
What is BOLA and why is it the number-one API risk?
BOLA, broken object level authorization, is listed as API1:2023 in the OWASP API Security Top 10. It happens when an API checks that a caller is authenticated but fails to check that the specific object being requested belongs to that caller. It ranks first because it is common, easy to exploit, and directly exposes other users and tenants data, and because object identifiers travel in almost every request an API serves.
What is the difference between BOLA and IDOR?
They describe the same underlying flaw from two different vocabularies. IDOR, insecure direct object reference, is the older web application term for tampering with an identifier to reach data you should not. BOLA is the API-specific name OWASP adopted for the same root cause in API1:2023. The fix is identical either way: enforce object-level authorization on the server for every request that references an object. See BOLA vs IDOR for the full side-by-side comparison.
How do you test for BOLA vulnerabilities?
Provision two accounts per role, establish an authenticated baseline for each object, then replay one account's requests substituting another account's object identifiers across every verb, GET, PATCH, PUT, and DELETE, not only reads. Operator automates this by enumerating every object-scoped operation from your spec and replaying identities across the full matrix, on every rerun. The full manual methodology is set out step by step earlier on this page.
Can a scanner detect BOLA?
Not reliably. A scanner sees that an endpoint returns a valid response for a valid identifier and has no way to know that identifier belongs to a different account, so a data breach and a correct response look identical to it. Detecting BOLA requires two authenticated identities and a comparison of what each is permitted to reach, which a scanner running a single session was never built to do.
What do you need from us to test for BOLA?
One bearer token per role or tenant is enough. Operator uses the specification to enumerate every object-scoped operation, then replays one identity requests as another and compares the results. Two accounts in the same role is often the strongest test, because it proves horizontal access between peers rather than only vertical access between privilege levels.
How is a BOLA finding proven rather than just flagged?
Each finding ships with the full request and response evidence for both identities: the request made as account A referencing account B object, the successful response containing B data, and the baseline showing the object never belonged to A. That evidence is reproducible, so your engineers confirm and fix it on the first pass instead of triaging a maybe.
Prove whether your API leaks across accounts
Give us one token per role and the agent will test every object-scoped operation for cross-account access, then hand you reproducible evidence. See also BFLA testing for function-level authorization.