Broken object level authorization is the most common serious flaw in modern APIs, and it is easy to introduce by accident. The bug is simple to state: the server hands back an object because the request asked for it, without checking that the caller is allowed to see that specific object. This post explains broken object level authorization and its close cousin IDOR, shows a worked API example, and covers how to find the bug and how to fix it.
What is broken object level authorization
An API endpoint usually identifies a thing by an id. You ask for order 1001, the server looks up order 1001, and it returns the data. The missing step is the check that this order belongs to you. When that check is absent, any logged in user can read or change objects that belong to other users just by naming their ids. That is broken object level authorization.
What is IDOR
IDOR stands for insecure direct object reference. It is the older name for the same idea. A direct object reference is when the id in the request maps straight to a record in the database, like a row primary key. The reference is insecure when the server trusts it without an ownership check. So IDOR describes the exposed id, and broken object level authorization describes the missing check behind it. In practice people use the two terms for the same class of bug.
The id in the URL tells the server which object to fetch. It must never decide who is allowed to fetch it.
A broken object level authorization example
Take an invented app, Acme Notes, that lets people place orders. A signed in user opens their order history and the browser calls this endpoint.
GET /api/orders/1001
Authorization: Bearer eyJhbGc...tokenForUserA
200 OK
{
"id": 1001,
"user_id": 42,
"total": "38.00",
"shipping_address": "12 Oak Street, Apt 4",
"items": ["Notebook", "Pen set"]
}
The user owns order 1001, so this response is correct. Now they change one digit in the URL and send the same token.
GET /api/orders/1002
Authorization: Bearer eyJhbGc...tokenForUserA
200 OK
{
"id": 1002,
"user_id": 77,
"total": "210.00",
"shipping_address": "98 Pine Avenue",
"items": ["Desk lamp", "Monitor stand"]
}
Order 1002 belongs to user_id 77, a different person. User A is still authenticated, and the server still returned the record. The token proved who the caller is. Nothing proved the caller owns this order. That gap is the whole bug. By walking the ids from /api/orders/1000 upward, the same caller can read every order in the system, including names, addresses, and totals.
It is not only reads
The same gap applies to writes. If the app exposes PATCH /api/orders/1002 or DELETE /api/orders/1002 with no ownership check, a user can edit or delete another person’s order. A profile endpoint like PUT /api/users/77/email with the same flaw lets an attacker take over an account by changing its recovery email. The id can also live in a request body or a query string, not just the path, so {"invoice_id": 1002} deserves the same scrutiny as a URL.
Why APIs are especially prone to this
Server rendered web apps often built one page that already filtered records to the current user. APIs split that into many small endpoints, and each one fetches objects by id on its own. Every endpoint becomes a separate place where the ownership check can be forgotten. A few reasons this class of bug keeps appearing:
- Object ids are visible and guessable. Sequential integers like 1001 and 1002 advertise that 1003 exists. Even random ids do not fix the bug, they only make it harder to find by guessing.
- The check is per object, not per route. Login and role checks happen once at the edge. Object ownership has to be checked on every single fetch, and it is easy to miss one endpoint out of fifty.
- Frameworks do not add it for you. Most routing layers confirm the user is logged in. Very few know that order 1002 must belong to the caller. That logic is yours to write.
- Nested and indirect references multiply the surface. Endpoints like
/api/users/42/orders/1001/items/9have several ids, and a check on one does not cover the others.
How to detect it
You find this bug by behaving like a real user with two accounts, then asking whether one account can reach the other’s objects.
- Create two test users. Sign in as user A and as user B. Note the object ids that belong to each.
- Swap the ids. While logged in as A, request B’s objects: change
/api/orders/1001to B’s/api/orders/1002with A’s token. A correct server returns403 Forbiddenor404 Not Found. A200 OKwith B’s data is the finding. - Repeat for every verb. Try GET, then PATCH, PUT, and DELETE on the same id. Read access and write access fail separately.
- Check ids in every position. Path, query string, JSON body, and headers can all carry an object reference.
- Watch for indirect leaks. A list endpoint, a search result, or an export job can hand back objects the caller should not see, even when the direct fetch is locked down.
This is hard to catch with a scanner that only matches known payloads, because there is no payload. The request is well formed and the id is valid. Finding it means understanding what each object is, who should own it, and then testing that assumption directly. More on access control bugs is here.
The fix: authorize every object on the server
The cure is one rule applied everywhere: before returning or changing an object, confirm the authenticated caller is allowed to act on that exact object. Do this on the server, in the data layer, not in the client.
The earlier example is fixed by scoping the lookup to the caller. Instead of fetching by id alone, fetch by id and owner together.
def get_order(order_id, current_user):
order = db.orders.find_one(
id=order_id,
user_id=current_user.id, # ownership is part of the query
)
if order is None:
return Response(status=404)
return Response(order)
Now order 1002 is invisible to user A, because the query asks for an order with that id that also belongs to A. Some practices that make this reliable across a whole codebase:
- Scope queries to the owner by default. Filter by tenant or user id in the data access layer so an unscoped lookup is the exception, not the norm.
- Centralize the check. Put authorization in one policy function each endpoint calls, so the rule is written once and reused, not copied and forgotten.
- Return 404 for objects the caller cannot access. A 403 confirms the object exists. A 404 reveals less.
- Do not rely on hard to guess ids alone. Random UUIDs reduce guessing, but the server must still check ownership. Obscurity is not authorization.
- Write a test per endpoint. For each object route, add a test where user A requests user B’s object and asserts the response is denied. This keeps the bug from coming back.
Broken object level authorization is a logic bug, not a string in a payload. Finding it means knowing what each object is, who should own it, and then proving the server agrees, which is exactly the kind of assumption an autonomous researcher that tests application logic is built to check. Read more about how UnboundCompute works.



