[BEE-2016] Broken Object Level Authorization (BOLA)
INFO
BOLA occurs when an API authenticates a user correctly but fails to verify that the authenticated user is allowed to access the specific resource they requested — letting any user reach any other user's data by changing an ID in the request.
Context
OWASP has ranked Broken Object Level Authorization as the number-one API security risk in both the 2019 and 2023 editions of the OWASP API Security Top 10. The rationale is straightforward: APIs expose endpoints that accept resource identifiers — order IDs, user IDs, document IDs — as path parameters or query parameters, and most APIs correctly authenticate who is making the request but inconsistently verify whether that authenticated user should be allowed to access the specific object identified by that parameter.
The vulnerability predates the API era under its older name, Insecure Direct Object Reference (IDOR), which was coined in the OWASP Top 10:2007 to describe a broader class of problems where an application exposes a direct reference to an internal object without verifying access. BOLA is the API-specific reframing: the problem is not the direct reference itself (using orderId=123 in a URL is not inherently wrong), but the missing authorization check at the object level. The shift in terminology reflects a shift in where the problem lives — not in URL design, but in authorization logic that must fire on every object access.
The most illustrative large-scale BOLA incident on record is the 2018 USPS Informed Delivery breach. The Informed Visibility API exposed account data — email, username, user ID, account number, mailing campaign data — for any user ID a caller supplied. Authentication was required; authorization was not. Any logged-in USPS account holder could query the account details of all 60 million other users. The vulnerability was reported by a researcher in August 2018 and remained unpatched for over a year before Krebs on Security published it in November 2018, forcing a fix. There was no exploitation of sequential guessability: the API accepted any valid user ID, and USPS user IDs were not secret.
Uber disclosed a similar issue in 2016: by modifying UUID and token values in a session request, a researcher could retrieve trip history, driver details (UUID, license plate), and passenger names belonging to other accounts. The research-to-fix timeline was 11 days — unusually fast for a systemic authorization gap — but the exposure window before discovery was unknown.
Design Thinking
The root of BOLA is a mismatch between where developers think authorization happens and where authorization actually needs to happen.
Route-level middleware enforces function-level authorization: "Does the authenticated user have the read:orders permission?" This check fires once when the request reaches the router. It answers whether a user is allowed to access the /orders collection endpoint at all.
Object-level authorization is different: it must answer whether this user is allowed to access order 12345 specifically. This check cannot live in generic middleware, because the middleware does not yet know which object the handler will fetch. It must live in the handler itself, after the object is identified but before its data is returned.
Most BOLA bugs are not clever. They follow a pattern: a developer implements authentication and adds middleware that checks whether the user has the right role to reach the endpoint. The handler fetches the requested object. The developer considers authorization done. The gap — "does the object belong to this user?" — is never filled because neither authentication nor role-based middleware answers that question.
BOLA vs. BFLA: BOLA (API1) is horizontal privilege escalation — an attacker accesses objects belonging to other users at the same privilege level. Broken Function Level Authorization (BFLA, API5) is vertical privilege escalation — an attacker invokes administrative functions that require higher privilege than they hold. A regular user reading another user's orders is BOLA. A regular user calling DELETE /admin/users/5 is BFLA. Both require authorization fixes, but in different places.
Best Practices
Always Check Ownership After Fetching
MUST verify that the authenticated user is authorized to access the fetched object, in every handler that fetches an object by a user-controlled identifier. This check cannot be delegated to middleware, because middleware does not know the fetched object's owner.
# VULNERABLE: authentication passes, ownership never checked
@router.get("/orders/{order_id}")
async def get_order(order_id: str, current_user: User = Depends(get_current_user)):
order = await db.orders.get(order_id)
if order is None:
raise HTTPException(404)
return order # returned to any authenticated user, regardless of who owns it
# SECURE: fetch, then verify ownership
@router.get("/orders/{order_id}")
async def get_order(order_id: str, current_user: User = Depends(get_current_user)):
order = await db.orders.get(order_id)
if order is None:
raise HTTPException(404)
if order.customer_id != current_user.id: # ownership check
raise HTTPException(403) # 403, not 404
return orderThe choice between 403 and 404 on the authorization failure deserves attention. Returning 403 confirms that the resource exists. Returning 404 avoids information disclosure (the attacker cannot distinguish "this order does not exist" from "this order belongs to someone else"). For most backend APIs, returning 404 on unauthorized object access — what OWASP calls "use the same response for unauthorized vs not found" — reduces information leakage. Choose consistently.
Scope Database Queries to the Authenticated User
SHOULD scope database queries to the authenticated user's ID rather than fetching by ID and then checking ownership. Scoped queries eliminate the check-then-use race and remove the possibility of returning unauthorized data if the ownership check is accidentally absent:
-- VULNERABLE: fetches first, checks second
SELECT * FROM orders WHERE id = $1;
-- then check order.customer_id == current_user.id in application code
-- SECURE: ownership enforced at the query level
SELECT * FROM orders WHERE id = $1 AND customer_id = $2;
-- returns nothing if the order doesn't belong to this userIn ORMs, the equivalent is filtering by the owning user in every query:
# Django ORM: always scope to the current user's queryset
order = Order.objects.filter(id=order_id, customer=request.user).first()
if order is None:
return HttpResponse(status=404) # covers both not-found and unauthorizedUse Non-Sequential, Non-Guessable IDs — But Know Their Limits
SHOULD use UUIDs or other non-guessable identifiers for any resource that is directly accessible via API. Sequential integer IDs (order_id=1, order_id=2) make enumeration trivial: an attacker who finds one valid ID can iterate through all others. UUIDs eliminate guessability.
Non-guessable IDs are defense-in-depth, not a substitute for authorization checks. The USPS breach involved arbitrary user IDs, not sequential integers. An attacker who learns a single UUID (from a shared link, a URL in logs, an error message, or a disclosed bug report) can exploit a BOLA vulnerability just as easily as with a sequential ID. Authorization checks are always required.
Centralize Object-Level Authorization Logic
SHOULD define object-level authorization policies in a single, testable location rather than scattering ownership checks across handlers. Scattered checks drift: a new developer adds a handler, forgets the ownership check, and the gap goes unnoticed until a security review or a breach.
One pattern: a repository or service layer that always enforces ownership:
class OrderRepository:
async def get_for_user(self, order_id: str, user_id: str) -> Order:
"""Always scoped — callers cannot bypass the ownership check."""
order = await self.db.get("SELECT * FROM orders WHERE id=$1 AND customer_id=$2",
order_id, user_id)
if order is None:
raise NotFound()
return orderHandlers call order_repo.get_for_user(order_id, current_user.id) and cannot accidentally call an unscoped variant. There is no unscoped variant.
For organizations with many services and complex permission models, a dedicated authorization policy engine such as Open Policy Agent (OPA) can evaluate Rego policies that encode object-level rules:
# OPA policy: a user may read an order only if they own it or are an admin
allow {
input.action == "read"
input.resource.type == "order"
input.user.id == input.resource.owner_id
}
allow {
input.action == "read"
input.resource.type == "order"
input.user.role == "admin"
}The application queries OPA before serving the object:
decision = opa.evaluate("data.orders.allow", {
"action": "read",
"user": {"id": current_user.id, "role": current_user.role},
"resource": {"type": "order", "id": order.id, "owner_id": order.customer_id},
})
if not decision:
raise HTTPException(403)This decouples authorization logic from application code, makes policies auditable, and creates a single point to test and enforce object-level rules across multiple services.
GraphQL and Bulk APIs
MUST apply object-level authorization on every resolver in a GraphQL API, not only at the query root. GraphQL's nested resolution model means a user can reach an object through a permitted parent. If the resolver for Order.invoices does not check whether the current user owns the parent order, an attacker can construct a query that traverses to data they should not see.
MUST NOT rely on query complexity limits or rate limits to prevent BOLA via GraphQL batching. GraphQL allows alias-based batching of many operations in a single request:
{
order_1: order(id: "uuid-1") { total, customerName }
order_2: order(id: "uuid-2") { total, customerName }
order_3: order(id: "uuid-3") { total, customerName }
}If the order resolver does not verify ownership, 500 UUIDs can be checked in one HTTP request. Object-level authorization in the resolver is the only effective control.
Testing for BOLA
The standard BOLA test requires two accounts:
- Authenticate as User A. Create or identify a resource owned by User A (e.g., order
uuid-A). - Authenticate as User B.
- Make a request to the User A resource using User B's session:
GET /orders/uuid-A. - If the response returns order data, the endpoint is vulnerable.
Automated security scanners struggle with BOLA because the test requires understanding which objects belong to which user — context the scanner does not have. Manual testing or authenticated regression tests with two fixture accounts are the reliable methods.
Visual
Related BEEs
- BEE-1001 -- Authentication vs Authorization: BOLA is an authorization failure, not an authentication failure — the user's identity is verified; their permission to access the specific object is not
- BEE-1005 -- RBAC vs ABAC Access Control Models: RBAC alone cannot prevent BOLA; ABAC's attribute-based policies can encode ownership as an attribute and are a closer fit for object-level authorization
- BEE-2008 -- OWASP API Security Top 10: BOLA is API1:2023 — the most critical API security risk
- BEE-2015 -- SSRF: both BOLA and SSRF are about missing authorization on which resources the system will access; SSRF on network resources, BOLA on data objects
- BEE-18007 -- Database Row-Level Security: PostgreSQL RLS can enforce object-level ownership at the database layer, providing defense-in-depth against application-layer BOLA bugs
References
- OWASP API Security Top 10:2023 — API1:2023 Broken Object Level Authorization — owasp.org
- OWASP API Security Top 10:2023 — API5:2023 Broken Function Level Authorization — owasp.org
- PortSwigger Web Security Academy. Insecure direct object references (IDOR) — portswigger.net
- Brian Krebs. USPS Site Exposed Data on 60 Million Users — krebsonsecurity.com, November 2018
- Inon Shkedy. A Deep Dive on the Most Critical API Vulnerability — BOLA — inonst.medium.com
- Open Policy Agent Documentation — openpolicyagent.org
- Open Policy Agent. HTTP API Authorization — openpolicyagent.org
- PortSwigger Web Security Academy. GraphQL API vulnerabilities — portswigger.net
- OWASP Testing Guide. Testing for Privilege Escalation — owasp.org
- OWASP GraphQL Cheat Sheet — cheatsheetseries.owasp.org