Here is the whole vulnerability, in one exchange.
You are logged in. You open one of your own invoices and the address bar
reads /invoices/1043. You change it to /invoices/1042.
The page loads. It is not your invoice.
That is Insecure Direct Object Reference. There is no clever payload, no injection, no race condition. The application took an identifier from the request, looked it up, and returned it, without ever asking whether the person making the request was entitled to that particular record.
Why scanners do not find this
Automated tooling struggles here, and it is worth understanding why, because it explains the gap between "we ran a scan and it was clean" and "we are actually fine."
A scanner sees a request to /invoices/1042 return HTTP 200 with
a well-formed page. That is what a correct response looks like. The scanner has
no way to know that invoice 1042 belongs to a different company, because it has
no model of who owns what. There is no error to detect, no anomalous status
code, no malformed output. Everything about the response is normal except the
one thing that matters.
Finding IDOR requires two things a scanner does not have: authenticated access to more than one account, and knowledge of which records belong to which. That makes it a manual check. It also makes it a cheap one.
The test
You need two accounts in different tenants. If your product has an organisation or workspace concept, they must be in different ones; two users in the same workspace are usually supposed to see each other's data, which tells you nothing.
- Sign in as account A. Create or find a record that clearly belongs to A. An invoice, an uploaded file, a project, a customer note. Something with recognisable content.
- Note its identifier. It will be in the URL, or in the network tab of your browser's developer tools if the app fetches it over an API.
- Sign in as account B, in a different browser profile or a private window so the sessions do not collide.
- Request A's record as B. Paste the URL. Or, if it is an API call, replay the request with B's session and A's ID.
- Read what comes back.
Three possible outcomes, and only one of them is good:
- 403 or 404. The application checked, and refused. This is what you want.
- A's data renders. You have found an IDOR. Stop and fix it.
- A partial render, or an error page that still leaks something. A 500 that includes a record title, or a page shell that shows the customer name before failing to load the body. Still a leak, and easy to miss if you only check the status code.
Where to test, in priority order
You will not test every endpoint. Start with the ones where a leak would be worst, which is usually not the same as the ones that are easiest to reach.
- File downloads. Uploaded documents are frequently served from a URL that is checked less carefully than the page linking to it. A signed URL that never expires is the same problem wearing a hat.
- Exports and reports. CSV and PDF generation often runs through a different code path that was written later and reviewed less.
- Anything with an ID in the request body rather than the URL. These get tested less because they are less visible, and they are just as exploitable.
- Update and delete, not only read. An application that correctly refuses to show you someone else's record may still accept your request to modify it. Test the write path separately.
- Nested resources.
/orgs/12/projects/88may check that you belong to org 12 and never check that project 88 belongs to it.
That last pattern is worth dwelling on, because it passes a casual review. The check exists. It is just checking the wrong thing.
UUIDs are not the fix
A common response is to replace sequential integers with UUIDs so the IDs cannot be guessed. This is worth doing and does not solve the problem.
Unguessable identifiers prevent someone idly typing an adjacent number. They do nothing once an ID becomes known, and IDs become known constantly: in shared links, in support tickets, in exported spreadsheets, in URLs pasted into email, in browser history on a shared machine, in referrer headers sent to third-party analytics. An identifier is not a secret. It travels, because travelling is what identifiers are for.
The fix is an authorisation check at the point of lookup: does the authenticated principal have a right to this specific object? UUIDs raise the effort of discovery. They do not answer that question.
Fixing it properly
The durable fix is to make the safe path the default, rather than relying on each developer to remember the check.
Scope queries at the data layer so the tenant filter is structural rather than optional. Instead of fetching by ID and then checking ownership, fetch by ID and owner in the same query. A lookup that cannot express "find this record regardless of who owns it" cannot accidentally do it.
If you are on Postgres, Row-Level Security gives you this at the database level, which is the strongest version: the filter applies even to code that forgot about it. The two controls are the same idea at different layers, and RLS is the one that survives a developer's mistake.
Then add a test. A single automated test that signs in as B, requests A's record, and asserts a 403 will catch the regression when somebody adds a new endpoint next year. This is one of the rare cases where one small test covers a whole vulnerability class for a particular resource.
If you find one
Fix the endpoint first. Then ask two follow-up questions, because a single IDOR is rarely alone:
- Was it exploited? Your access logs hold the answer, if you log the authenticated user alongside the requested resource. If you do not log that pairing, you cannot tell, and that is worth changing regardless of what you find here.
- Where else does this pattern appear? The flaw usually reflects a habit rather than a one-off. Search the codebase for the same lookup shape.
Whether an exploited IDOR triggers a breach notification obligation depends on what was exposed and which regime applies to you. That is a question for whoever advises you on data protection, not one to settle from a blog post.
Why this one is worth your afternoon
Most of the readiness guide describes work that takes days. This one takes an afternoon at most, needs no tooling you do not already have, and tests the control that separates your customers from each other.
It is also the check that most directly answers the question a prospect is really asking when they send you a security questionnaire. Not "do you have a policy," but "can your other customers see my data." You can answer that with confidence only if you have tried it.