Change one number in a URL and suddenly you are looking at someone else's invoice. That is an Insecure Direct Object Reference, or IDOR, and it is one of the most common and most damaging bugs in web apps and APIs. It needs no special tools to exploit, it slips past most automated scanners, and it often exposes exactly the data you most need to protect: orders, documents, messages, property listings, payroll. This guide explains what IDOR is, shows how it looks in real code, and walks through the habits that prevent it for good.
What is an IDOR vulnerability?
An IDOR happens when an application uses an identifier supplied by the user (an ID in the URL, a form field, a JSON body or a header) to fetch or change a record, without checking that the current user is allowed to touch that specific record. The app checks who you are (authentication) but forgets to check what you may access (authorization).
OWASP folds IDOR into "Broken Access Control", which has sat at the top of the OWASP Top 10 since 2021. In the OWASP API Security Top 10 the same problem leads the list as "Broken Object Level Authorization" (BOLA). Different names, same mistake.
IDOR examples you will recognise
Here is the classic case. A logged-in user opens their invoice:
GET /api/invoices/1042
Authorization: Bearer <valid token for user 7>The backend does this:
// Laravel controller - vulnerable
public function show($id)
{
return Invoice::findOrFail($id);
}The token is valid, so the request is "authenticated". But nothing checks that invoice 1042 belongs to user 7. Change the URL to /api/invoices/1043 and you get a stranger's invoice. A short script can loop through every ID in minutes.
IDOR is not limited to reads. The same pattern shows up as:
Updates:
PUT /api/profile/55lets you edit another user's email address, which can lead to full account takeover.Deletes:
DELETE /api/listings/880removes a competitor's property listing.Hidden fields: a checkout form posts
account_id=12, and changing it bills someone else's account.Files:
/downloads?file=contract_2231.pdfserves any contract on the server.Nested resources:
/api/projects/3/tasks/77checks that you can see project 3, but never checks that task 77 is actually in project 3.
Why IDOR bugs are so common
IDOR is rarely caused by one careless developer. It usually comes from structure:
Authorization lives in the UI. The frontend only shows your own records, so everyone assumes the API is safe. Attackers do not use your UI.
Checks are copy-pasted per endpoint. Fifty endpoints means fifty chances to forget one, especially in a rushed hotfix.
Admin and multi-tenant logic gets mixed. An endpoint written for admins is later reused for regular users without adding a check.
Scanners do not catch it. A tool cannot know that invoice 1043 should be off-limits for user 7. Only your business rules know that.

How to prevent IDOR: the core rule
Every request that touches a record must answer one question on the server: is this user allowed to perform this action on this exact object? Not "is the user logged in", and not "can the user see invoices in general". This specific invoice, this specific action.
The rest of this guide is just reliable ways to make that check happen every time.
1. Scope queries to the current user or tenant
The simplest fix is to never look up a record globally. Look it up inside what the user owns:
// Laravel - safe
public function show(Request $request, $id)
{
return $request->user()->invoices()->findOrFail($id);
}-- SQL equivalent
SELECT * FROM invoices WHERE id = $1 AND user_id = $2;If the invoice belongs to someone else, the query returns nothing and the user gets a 404. In multi-tenant apps, apply a global scope on tenant_id so every query is filtered automatically and a forgotten filter cannot leak data across companies.
2. Centralise object-level authorization
Do not scatter if ($invoice->user_id !== auth()->id()) checks across controllers. Put the rule in one place and call it everywhere. In Laravel that is a policy:
// app/Policies/InvoicePolicy.php
public function view(User $user, Invoice $invoice): bool
{
return $user->id === $invoice->user_id
|| $user->hasRole('accountant');
}
// Controller
public function show(Invoice $invoice)
{
$this->authorize('view', $invoice);
return $invoice;
}Express, Django, Spring and .NET all have the same idea (middleware, permission classes, voters, resource-based handlers). The point is that the rule is written once, tested once, and reused.
3. Deny by default
Make new endpoints closed until someone explicitly grants access. A route group that requires a policy check, or a test that fails when a controller action has no authorize call, turns "I forgot" into a build failure instead of a breach.
4. Validate nested and related IDs
When a request carries several IDs, check every relationship. For /projects/3/tasks/77, load task 77 through project 3 ($project->tasks()->findOrFail(77)) rather than loading it on its own. The same goes for IDs inside request bodies, such as a category_id or assignee_id that must belong to the same tenant.
5. Use non-guessable IDs, but only as a second layer
Switching from 1, 2, 3 to UUIDs or random public IDs makes enumeration much harder, and it is worth doing for public-facing URLs. But UUIDs leak through emails, logs, shared links and browser history. An unguessable ID with no authorization check is still an IDOR, just a slower one. Treat random IDs as defence in depth, never as the fix.
6. Return 404 for records the user cannot access
If you answer 403 for "exists but not yours" and 404 for "does not exist", attackers can map which IDs are real. For most user-owned data, returning 404 in both cases leaks less.
7. Protect files properly
Do not serve private uploads straight from a public folder. Keep them in private storage and stream them through a controller that runs the same policy check, or hand out short-lived signed URLs generated only after the check passes.
How to test for IDOR
Because scanners miss it, testing has to be deliberate:
Two-account test. Create user A and user B. As A, record every request that contains an ID. Replay each one with B's session. Any success is a bug.
Automate it. Write feature tests that try to read, update and delete another user's record and assert a 404 or 403. Run them in CI on every pull request.
Check every verb. Teams often protect
GETbut forgetPATCH,DELETEand export or bulk endpoints.Use proxy tools. Burp Suite with an authorization-testing extension such as Autorize, or OWASP ZAP, replays traffic with a second user's session automatically.
Log and alert. Many 404s on sequential IDs from one account is a strong sign someone is probing. Rate-limit and alert on it.
A quick IDOR prevention checklist
Every query for user data is scoped to the current user or tenant.
Object-level rules live in policies or middleware, not ad-hoc
ifstatements.New endpoints are denied by default until authorization is added.
Nested and body IDs are validated against their parent records.
Public identifiers are random, but never trusted on their own.
Private files go through an authorized controller or signed URLs.
Cross-user tests run in CI for read, update and delete.
Final thoughts
IDOR is easy to introduce and easy to exploit, but it is also very preventable. Once your team treats "can this user touch this object?" as a required step in every handler, backed by scoped queries, central policies and cross-user tests, the whole class of bug largely disappears. Start by auditing the endpoints that handle money, personal data and documents, then make the safe pattern the default for everything you build next.




Comments
Join the discussion. Replies load on demand, up to 5 levels.
0 threads
No comments yet
Be the first to start the conversation.