A researcher deleted professional certifications from any HackerOne user with a single authenticated DELETE request. The endpoint existed. The JWT was valid. The question the server never asked: does this object belong to the requester? Bounty: $12,500 (H1 #2122671).
Authentication confirms identity. Object-level authorization confirms ownership. Most APIs run the first check and skip the second. The gap between "this token is valid" and "this token owns object 4729" is the entire attack surface. BOLA has held the top spot in OWASP API Security Top 10 for 4 cycles. It is ranked API1:2023, with exploitability rated "Easy."
The Ownership Gap
Authentication is about identity. Authorization is about ownership. GET /invoices/4729 with a valid JWT returns HTTP 200 for any authenticated user when the server checks only the token, not the invoice owner.
Frameworks do not enforce this by default. Invoice.find(params[:id]) is syntactically identical to current_user.invoices.find(params[:id]). The developer who writes the first version receives no error, no failing test, and no signal that authorization is missing. The server returns HTTP 200 and the other user's data follows in the response body.
The result is invisible to conventional monitoring. Logs show HTTP 200. Anomaly alerts do not fire. The attacker reads another user's data and the system records a successful transaction.
ID Surface Taxonomy
Not every ID type exposes BOLA in the same way. The attack surface varies by identifier type and how the server processes it.
Sequential integers. An endpoint like /api/orders/12345 is enumerable by definition. The attacker iterates from N-1 to N+1 without friction. OWASP documents this pattern in approximately 40% of recorded API attacks.
UUID v1. Encodes timestamp at 100-nanosecond resolution plus MAC address. The sandwich attack works like this: the attacker generates a UUID immediately before and one immediately after the victim's request. The victim's UUID sits between the two, isolatable in millisecond windows during burst creation.
IDs leaked by other endpoints. A listing endpoint returns objects with numeric id fields in the response body. Those IDs feed requests to endpoints without ownership checks. The attacker collects IDs from legitimate responses and tests them across different endpoints without enumerating sequences.
Exposed indirect references. /api/files/download?token=abc123 appears opaque. When the token is a deterministic hash of the internal file_id, the indirection is cosmetic and enumeration remains trivial.
CVEs and Bounty Reports
CVE-2024-45719, CVSS 9.0. Apache Answer used UUIDv1 as an authorization token. Two users creating accounts in close time windows generated tokens with adjacent timestamps. An attacker with one controlled account could isolate another account's token and take over the session. Fixed in version 1.4.0.
CVE-2021-22863. GitHub Enterprise Server, GraphQL API. An authenticated user could modify pull request collaboration permissions without authorization checks at the resolver level. The guard existed at the query root. Nested resolvers had no check. Fixed in versions 3.0.21, 3.1.13, and 3.2.5.
H1 #2122671, HackerOne, $12,500. An authenticated DELETE with the victim's certificate ID deleted the certificate without ownership verification. Any authenticated user on the platform could delete another user's professional credentials.
H1 #415081, PayPal, $10,500. Internal endpoint /businessmanage/users/api/v1/users exposed with user ID in the path. No verification that the caller has permission to manage that specific user. An administrative API inadvertently accessible to any authenticated account.
H1 #723461, Mail.ru (Pandao), $3,000. Numeric order ID in the delivery address endpoint path. An authenticated request with another user's order ID returned the data without restriction. No ownership join in the query.
Cascading IDOR
The most destructive pattern is not access to a single object. It is access to a parent object that exposes all children without independent verification at each level.
In multi-tenant systems, GET /orgs/{org_id}/users returns all users in an organization. The server trusts the org_id from the request. Without validating that the token belongs to that org, any authenticated account becomes an administrator of any organization. The org_id should come from the session, never from the request body or path.
The three-hop chain works like this: the attacker accesses object A with legitimate authorization. The response from A contains the ID of object B. The endpoint for B has no direct authorization check. The attacker uses B's ID to reach object C without any direct check on C.
This chain emerges from an implicit assumption: if the user reached object B, they must be authorized. That reasoning underlies most complex BOLA bugs reported in bounty programs. No individual hop looks vulnerable in static review. The failure exists in the chain.
Detection via Differential Testing
Static analysis does not detect authorization failures at runtime. Automated scanners lack cross-account ownership context. The only reliable detection method is two-account testing.
The procedure: Account A creates the object and captures the returned ID. Credentials switch to Account B's token. The request repeats with the same ID. The expected result is 403. Any 200 response is a confirmed failure.
This test must cover all methods: GET, PATCH, PUT, DELETE. An endpoint returning 403 on GET may return 200 on DELETE, because access control was added only for reads.
The MAGO team tool (mago.team) automates differential two-account testing. It creates objects with one account, attempts access with another, and flags every 200 response instead of 403.
Defense
Authorization belongs at the service layer, not at the gateway or authentication middleware. Gateways verify tokens. They do not verify object ownership. Every resolver, handler, and query must explicitly check whether the object belongs to the authenticated user.
Indirect reference maps. Internal IDs never reach the client. External tokens map to internal IDs per session. An opaque token per user eliminates enumeration and forces the server to resolve indirection with session context.
Ownership-scoped tokens. The JWT sub claim binds to the object's owner_id at creation time. The resolver verifies the match before any read or write operation.
Audit log on cross-user access attempts. Every attempt to access an object whose owner_id does not match the token's sub generates an audit event. In volume, those events identify active enumeration before the attacker completes the chain.
Every endpoint that accepts an external ID must answer one question: does this ID belong to the authenticated requester? Frameworks do not ask that question by default. Until the answer is in the code, the question is being answered by whoever tests from outside.
Top comments (0)