Insecure Direct Object References (IDOR): When Your IDs Leak Data
Change the number in the URL and you're reading someone else's invoice. IDOR, also called Broken Object Level Authorization, is the top risk in the OWASP API Security list, and it survives code review because every individual endpoint looks fine. Here's why it happens, why UUIDs don't fix it, and how to make the check impossible to forget.
Change One Digit
You log into a billing app and open your latest invoice. The URL is /invoices/4127. Out of curiosity, you change it to /invoices/4126, and you're looking at another company's invoice: their address, line items, and what they pay.
Nothing was hacked. You were logged in, you sent a normal request, and the server returned exactly what the code told it to. The endpoint checked that you were authenticated. It never checked that invoice 4126 was yours.
This is an Insecure Direct Object Reference (IDOR). In API security it goes by Broken Object Level Authorization (BOLA), and it has been the first entry in the OWASP API Security Top 10 in both the 2019 and 2023 editions. It's common because it's easy to write, easy to miss in review, and trivial to exploit with a loop.
Why It Happens
Most endpoints are written in two steps: confirm who the caller is, then fetch what they asked for. The bug lives in the gap between them.
Authentication is usually handled once, in middleware, and applies to every route. Object-level authorization can't be: whether Alice may see invoice 4126 depends on the specific row, so the check has to happen after you know which row it is. That puts it inside each handler, written by hand, and every handler is a new chance to forget.
A few things make it easy to miss:
- The code looks right.
findById(req.params.id)is idiomatic, and frameworks generate it for you. - Tests pass. Most tests use one user who owns all the data. The bug only appears with two users.
- The frontend hides it. The UI only ever links to your own invoices, so nobody clicks their way into someone else's. Attackers don't use your UI.
🔴 This is how real breaches happen
In 2019, First American Financial exposed about 885 million mortgage-related documents through a website where document URLs used sequential numbers and the server didn't check who was asking. Anyone with one valid link could read the others by changing the number. The SEC later fined the company over its handling of the issue.
Try It: Enumerate the Invoices
You're logged in as Alice. A script requests a range of invoice IDs. Choose how IDs are generated and what the server checks.
Hover a cell to see the request. In UUID mode the attacker also holds 5 invoice IDs belonging to other users, picked up from shared links and logs. Random IDs stop the guessing; only the authorization check stops the leaked ones.
Start with Sequential integer and None: every red cell is another customer's invoice, and the whole range takes a few dozen requests. Then work through the other options:
- Random UUID, no check. Guessing stops working, since nobody can enumerate 2¹²² possibilities. But the attacker still reads every invoice whose ID they've seen in a shared link, a support email, a log file, or another API response. Those are still red.
- Owner check returning 403. No data leaks. But each orange cell tells the attacker that invoice exists, which is itself information: how many customers you have, or whether a particular ID is valid.
- Scoped query returning 404. Other people's invoices look exactly like invoices that don't exist. The attacker learns nothing.
⚠️ UUIDs are not authorization
Random IDs are a reasonable defense in depth: they stop enumeration and hide your record counts. They don't stop an attacker who has an ID, and IDs leak constantly through URLs, browser history, Referer headers, analytics tools, and screenshots. Time-ordered formats like UUIDv7 and ULID are still impractical to guess, but they reveal when a record was created. Use whichever ID format you like; just never let it be the thing that protects the data.
The Fix: Make Ownership Part of the Query
The most reliable fix is to stop fetching by ID alone. Put the caller's identity into the lookup itself, so a row that isn't theirs is never returned:
# Fetch, then check: works, but easy to forget and easy to get wrong invoice = db.invoices.find(id) if invoice.account_id != current_user.account_id: return 403 # also tells the caller the invoice exists # Scoped query: the row is invisible unless it's yours invoice = db.invoices.find_where(id = id, account_id = current_user.account_id) if invoice is null: return 404 # "not yours" and "doesn't exist" look the same
The scoped version has two advantages. It returns the same 404 for "doesn't exist" and "not yours," so it doesn't confirm which IDs are real. And it makes the safe path the short path: there's no separate if to leave out.
✅ Should it be 403 or 404?
Return 404 when the caller shouldn't know the object exists, which covers most user data and tenant data. Return 403 when its existence is already known and the caller lacks a specific permission, like a team member who can see a project but not delete it. GitHub returns 404 for private repositories you can't access for exactly this reason.
Make It Hard to Forget
A per-handler fix works until someone adds a new endpoint. The durable fixes move the check somewhere developers can't skip it.
| Approach | How it works | Trade-off |
|---|---|---|
| Scoped repository methods | Data access goes through methods that require the current user or tenant, like invoicesFor(user).find(id) | Simple and effective; relies on nobody bypassing the repository |
| Central policy layer | Every handler calls authorize(user, 'read', invoice); tools like Pundit, CASL, OPA, or Cedar hold the rules | Rules live in one place; the call itself can still be forgotten |
| Default ORM scopes / query filters | The ORM adds tenant_id = ? to every query automatically | Hard to forget; raw SQL and admin queries can escape it |
| Row-Level Security (Postgres RLS) | The database itself filters rows using a session variable for the current tenant | Enforced even for raw SQL; adds setup and debugging cost, and connection poolers need care |
| Fail-closed linting / tests | CI flags handlers that load objects without an authorization call | Catches regressions; needs maintenance as the codebase evolves |
For multi-tenant SaaS, the strongest setup combines two of these: tenant scoping at the data layer (ORM filters or RLS), so one tenant can never see another's rows, plus a policy layer for finer rules inside a tenant, like "only admins can export."
IDOR Hides in More Places Than GET
Reading by ID in the URL is the classic case, but the same mistake appears wherever a client supplies an identifier:
| Where | What it looks like |
|---|---|
| Writes and deletes | PUT /users/42 or DELETE /comments/9 without checking ownership. Often worse than reads. |
| IDs in the body | POST /transfers with from_account_id taken from the request instead of the session |
| Nested resources | /orders/5/items/77 checks that you own order 5, but item 77 belongs to order 6 |
| Mass assignment | PATCH /profile accepts an owner_id or role field and saves it |
| File storage | Download URLs built from predictable storage keys, with no signed URL or access check |
| Batch and export endpoints | POST /invoices/export with a list of IDs, checked as a whole or not at all |
| GraphQL | node(id:) or nested resolvers that fetch by ID without checking the parent's access |
The general rule: any identifier that comes from the client is a claim, not a fact. The server decides what the caller may touch, using the session, not the request body.
Testing for It
IDOR is easy to test for, and most teams don't. The pattern is the same for every endpoint:
Create two users (and two tenants)
Alice and Bob, each with their own data. If you're multi-tenant, put them in different organizations too.
Use Alice's session against Bob's IDs
For every endpoint that takes an identifier, call it as Alice with one of Bob's IDs. Include reads, writes, deletes, and exports.
Assert the right refusal
Expect 404 (or 403 where existence is already known), and assert that Bob's data is unchanged after write attempts.
Writing that as a shared test helper, applied to every route, catches most regressions. Security tools like Burp Suite's Autorize extension automate the same idea for manual testing: replay each request with a second user's session and compare the responses.
Key Takeaways
- Authentication isn't authorization. Knowing who the caller is says nothing about which rows they may touch.
- Every client-supplied ID is untrusted. In the URL, the body, a nested path, or a GraphQL argument.
- Scope the query, don't check afterwards.
WHERE id = ? AND owner_id = ?makes the safe path the default. - Prefer 404 over 403 when the caller shouldn't learn that the object exists.
- Random IDs are defense in depth, not a fix. IDs leak; the check has to hold even when the attacker has a real one.
- Move the check where it can't be skipped: scoped repositories, ORM tenant filters, Row-Level Security, or a policy layer.
- Test with two users. Single-user tests can't find this bug.
References
- OWASP API Security Top 10 (2023): API1 Broken Object Level Authorization
- OWASP Cheat Sheet: Insecure Direct Object Reference Prevention
- Krebs on Security: First American Financial Corp. leaked hundreds of millions of title insurance records (2019)
- PostgreSQL docs: Row Security Policies
- Sessions vs JWT: The Auth Token Debate