You're logged into an e-commerce site. You just placed an order, and the confirmation page shows:
GET /api/orders/1042
The server returns your order details. Everything looks normal.
Now consider what happens if you change that number:
GET /api/orders/1043
If the server returns that order too, without checking whether you're actually allowed to see it, that's the vulnerability. Not the fact that you changed the number. The fact that the server didn't notice you shouldn't have access to what came back.
This is Insecure Direct Object Reference, commonly called IDOR, or under the more precise modern framing, Broken Object Level Authorization (BOLA). The names describe the same underlying problem: an application that authenticates who you are but fails to verify whether you're authorized to access the specific object you're asking for.
Authentication Is Not Authorization
These two concepts get conflated constantly, and that conflation is where IDOR lives.
Authentication answers one question: who is this user? When you log in, the application establishes your identity. Your session token or cookie says "this request is from Alice."
Authorization answers a different question: what is Alice allowed to do? More specifically for IDOR: is Alice allowed to perform this operation on this particular object?
Alice
↓
Authenticated ✓
↓
Can access:
Order 1042 ✓ (Alice's order)
Order 1043 ✗ (Bob's order)
An application that correctly performs authentication but skips or misses authorization at the object level will happily hand Alice Bob's data, because all it checked was whether Alice was logged in.
What "Direct Object Reference" Actually Means
An object reference is just an identifier that lets the application locate something: a database row, a file, a resource. The identifier itself isn't the problem.
GET /users/42
GET /orders/1042
GET /invoices/781
GET /documents/a3f9...
These are all direct references. They're efficient and normal. The security question is what the server does when it receives one.
A vulnerable application treats possession of the reference as sufficient permission:
@app.get("/orders/<order_id>")
def get_order(order_id):
order = db.get_order(order_id)
return order
The application fetches whatever order matches the ID and returns it. It never asks whether the currently authenticated user is allowed to see that order. If you can construct or guess a valid ID, you can retrieve the corresponding object.
The fix is conceptually simple:
@app.get("/orders/<order_id>")
def get_order(order_id):
order = db.get_order(order_id)
if order.user_id != current_user.id:
return forbidden()
return order
Now the server checks that the object belongs to the requesting user before returning it. Unauthorized requests get a 403, not someone else's data.
The Bug Often Lives in the Query
One level deeper: authorization bugs frequently appear not in request handlers but in the data-access layer. The database query itself may be the right place to enforce scope.
Compare these two queries:
SELECT * FROM orders WHERE id = ?
versus:
SELECT * FROM orders
WHERE id = ?
AND user_id = ?;
The first retrieves any order matching the ID. The second retrieves an order only if it belongs to the current user. An unauthorized ID simply returns no rows, which the application can treat as a not-found or forbidden response.
Scoping queries to the current user's context makes authorization a property of data retrieval rather than a separate check that can be forgotten. It also means that if an authorization check is accidentally skipped somewhere in the application, the query itself doesn't return objects the user isn't allowed to see.
Unpredictability Is Not Authorization
A common response to IDOR vulnerabilities is to make IDs harder to guess. Instead of sequential integers, use UUIDs or opaque tokens:
/orders/7f3a92b4-e1d8-4c2a-bf19-3a8d1c7e05f6
This makes brute-force enumeration harder. It doesn't fix the underlying problem.
If the server still returns an order to any authenticated user who knows its ID, the authorization check is still missing. An attacker who obtains a UUID through another means, from a shared link, an API response, a log entry, simply uses it. Unpredictability raises the bar for unauthenticated access. It is not a substitute for checking whether the authenticated user is allowed to access a specific object.
This matters because it shifts developer thinking away from the real fix. The question isn't "how hard is the ID to guess?" It's "does the server verify that this user is allowed to access this object?"
Read Operations Are Not the Only Risk
IDOR isn't limited to GET requests. Object-level authorization applies across every operation:
GET /api/orders/1042 → view
PUT /api/orders/1042 → modify
DELETE /api/orders/1042 → delete
An application might correctly protect reads while leaving writes unguarded, or correctly protect deletion while leaving modification open. Each operation needs its own authorization check against the specific object being acted on. The underlying question is always the same: is this user allowed to perform this particular action on this particular object?
Client-Side Controls Don't Count
A frontend application might hide an edit button for objects the user doesn't own, or not display other users' orders in the UI. This provides no security.
An attacker communicates directly with the API, not through the browser's rendered UI. The fact that your application doesn't show a delete button to unauthorized users has no bearing on what happens when an unauthorized user sends a DELETE request directly.
Browser UI (hides unauthorized controls)
↓
Direct API request (bypasses the UI entirely)
↓
Authentication check
↓
Object lookup
↓
Authorization check ← this must exist on the server
↓
Response
The authorization check belongs in the server. Everything the frontend does is presentation. None of it enforces security.
Building Authorization That Holds
The defensive pattern follows from the mechanism. For every request that accesses a specific object, the server needs to establish three things: who is making the request, what object is being accessed, and whether that user is permitted to perform the requested operation on that object.
Authenticated user
+
Requested object
+
Requested action
↓
Authorization decision
One practical approach is to scope data access to the current user's context at the query level, as shown earlier. Another is to centralize authorization logic rather than scattering ad-hoc permission checks across individual endpoints, where they're easy to miss or apply inconsistently.
Testing also matters. Authorization bugs are invisible to tests that only check "authenticated users can access this endpoint." Tests need to verify that User A cannot access User B's objects, cannot modify User B's objects, and cannot delete them. The test cases that catch IDOR are specifically the cross-user ones.
Changing /orders/1042 to /orders/1043 isn't the vulnerability. If the server enforces authorization correctly, that request returns a 403. The vulnerability is what the server does when it retrieves an object without verifying the requesting user's permission to access it.
Authentication tells the application who you are. That's a necessary first step. Authorization tells the application what you're allowed to do. Object-level authorization goes one step further: it asks whether you're allowed to do it to this specific object.
IDOR happens in the gap between the first check and the third. Closing that gap is the entire fix.
Top comments (0)