August 14, 2026
The Vulnerability That Passes Every Code Review — IDOR for developers
Why findById(req.params.id) is one of the most expensive lines of code you can write…

By Fuzzyy Duck
10 min read
It's Thursday. The ticket is small: "Let customers download their invoice."
You've done this a hundred times. You add a route, pull the invoice by ID, render it, send it back.
app.get('/api/invoices/:id', requireLogin, async (req, res) => {
const invoice = await Invoice.findById(req.params.id);
res.json(invoice);
});app.get('/api/invoices/:id', requireLogin, async (req, res) => {
const invoice = await Invoice.findById(req.params.id);
res.json(invoice);
});There's a login check. The frontend only ever links to invoices the user actually owns. QA clicks through the flow and it works. The reviewer approves it in four minutes because there's nothing obviously wrong with it.
Months later, someone bored on a train changes 1041 to 1042 in the URL bar and gets somebody else's invoice. Name, address, amount, order history. They try 1043. Same thing. They write a ten-line script and walk away with your entire billing table.
That's an IDOR, an Insecure Direct Object Reference. It's the bug I run into more than any other, and it's not because developers are careless. It's because the insecure version and the secure version look almost identical, and only one of them is obviously wrong after you know what to look for.
This post is about how to see it before it ships. [Read it for free]
What IDOR actually is
An IDOR happens when your application uses a value supplied by the user to look up a resource, and never checks whether that particular user is allowed to have that particular resource.
That's the whole thing. Two parts:
- The user controls an identifier (
id,account_number,filename,orderRef,uuid, whatever). - The code trusts that identifier to point at something the user is entitled to.
The formal name in the OWASP API Security Top 10 is Broken Object Level Authorization (BOLA), and it sits at number one on that list. In the main OWASP Top 10 it lives under Broken Access Control, also number one. The CWE most people cite is CWE-639: Authorization Bypass Through User-Controlled Key.
Different names, same failure: authentication happened, authorization didn't.
The distinction that matters most
Authentication answers "who are you?" Authorization answers "are you allowed to do this, to this specific thing?"
Almost every team gets the first one right. Sessions, JWTs, OAuth, MFA that's a solved problem with good libraries. The second one is the part people improvise, endpoint by endpoint, and improvisation doesn't scale to 400 routes.
Misconceptions worth killing early
"We use UUIDs, so nobody can guess the ID."
UUIDs are a speed bump, not a control. IDs leak constantly through shared links, referrer headers, browser history, support tickets, CSV exports, mobile app logs, an old email, a screenshot in Slack, a GET /api/search endpoint that returns IDs for records you don't own. And an ex-employee or a churned customer already has a pile of valid ones. Randomness slows down enumeration; it doesn't stop targeted access.
"That ID never appears in the UI." The UI isn't your security boundary. Anyone can open DevTools, read your JavaScript bundle, and call the endpoint directly. Your API is public whether you designed it that way or not.
"The endpoint is behind login." Login proves the request came from a customer. It says nothing about which customer's data is being returned.
"We check the role."
Role checks are function-level ("can this user call the refund endpoint at all?"). IDOR is object-level ("can this user refund this order?"). Two customers can both legitimately have the customer role and still have no business reading each other's records. You need both checks; they are not substitutes.
Where this gets introduced
I've never seen an IDOR that was deliberate. They come from ordinary situations:
- The framework made it easy.
findById(),Invoice.find(params[:id]), scaffolded CRUD. Every framework gives you a one-liner that fetches by primary key. None of them scope by owner unless you ask. - The code grew a second consumer. An endpoint written for a single-tenant internal dashboard gets reused by the mobile app. The original assumption ("only admins hit this") quietly stopped being true and nobody updated the code.
- Authorization lived in the UI. The button is hidden, so the backend "doesn't need" the check. This is the most common root cause I see.
- Copy-paste. The first endpoint was written carefully. Endpoints two through nineteen were copied, and somewhere around six the ownership filter got dropped in a refactor.
- Sloppy nesting.
/orgs/:orgId/projects/:projectIdlooks scoped, then the handler ignoresorgIdentirely. Now you can pass your org and their project. - Bulk endpoints added late. The single-item route got a proper check. Then someone added a batch endpoint taking an array of IDs, and the loop inside checks nothing.
- Legacy internal APIs going public. A service that was only reachable from inside the VPC gets an API gateway in front of it as part of a "quick" integration. Its threat model was written for a network that no longer exists.
What it costs
The technical description of IDOR sounds mild "a user can view another user's record." The business version is not mild.
Every record, not one record. IDs are enumerable, so an IDOR on a single endpoint usually means the entire table. A scraper is trivial to write. There's no exploit development involved; it's a for loop.
It's often silent. Every request is authenticated and returns a 200. Your WAF sees nothing anomalous. Your error monitoring sees nothing. Unless you specifically log and alert on cross-tenant access patterns, you find out when a journalist emails you.
Real incidents:
- First American Financial (2019) — roughly 885 million documents including bank account numbers, mortgage records, and Social Security numbers were reachable by changing a sequential number in a URL. No authentication needed at all.
- USPS Informed Visibility API (2018) — an API let any authenticated account query the account details of roughly 60 million users.
- Optus (2022) — an internet-facing API endpoint exposed customer records including identity document numbers, affecting millions of Australians, and triggered national regulatory reform.
- Parler (2021) — sequential post IDs let researchers archive essentially the entire platform, including unredacted metadata.
Compliance. Under GDPR this is unauthorised disclosure of personal data reportable within 72 hours, with fines in the "percentage of global turnover" range. Under PCI DSS, exposing cardholder data across accounts is a straight requirement failure and can put a merchant's processing ability at risk. India's DPDP Act has its own reporting duties. HIPAA treats it as a breach of PHI.
Trust. "A hacker used a sophisticated attack" is a survivable press release. "Anyone could see anyone's data by editing a number" is not. That second sentence is the one that ends up in the headline, because it's the one journalists can explain to their readers.
Where the check goes missing
Authentication ran. The database did exactly what it was told. Every layer behaved correctly. The failure is the layer that isn't there.
This is what the flow should look like:
The root cause is architectural, not textual
Fixing this one route doesn't fix your application. The reason IDOR keeps recurring is that authorization is treated as a per-endpoint responsibility. Every new handler is a fresh opportunity for a developer to forget, and forgetting has no visible symptom.
Compare that to SQL injection. Most teams beat SQLi not through vigilance but through tooling: ORMs and parameterised queries made the safe path the default path. Nobody has to remember to escape quotes anymore.
Access control never got that treatment in most codebases. It's still hand-written, in a different shape, in every file.
The trust boundary is misplaced. Developers draw the boundary at the login page "everything past here is our users." The real boundary is around each object. Being a customer isn't the same as being this customer.
The principle being violated is complete mediation. Every access to every object must be checked, every time. Not once at login. Not once when the page loads. Every request.
Why code review misses it. Reviewers evaluate what the code does, and the code does something sensible. Missing authorization is an absence, and humans are bad at noticing absences. There's no red squiggle for a check that isn't there. If you want reviews to catch this, you have to make it a question on a list rather than something people spot.
Why SAST misses it. A static analyser can tell that req.params.id reaches a database query. What it can't tell is whether that query should have been scoped, because that's business logic. There's no universal rule that says "invoices belong to orgs." Some tools do better with taint tracking plus custom rules, but out of the box, expect false negatives.
The durable fix is structural. Pick one:
- Scoped repositories — data access goes through
invoiceRepo.forUser(user).findById(id)and there's no method that fetches unscoped. - A policy layer — one authorization module, called from every handler, with tests of its own. Laravel Policies, Pundit, CASL, Spring's method security.
- Database-enforced isolation — PostgreSQL row-level security with the tenant set per connection. Then even a buggy query can't cross tenants.
- Middleware that fails closed — routes must explicitly declare a policy; any route without one is rejected at startup. This is the one that scales, because it converts "someone forgot" into a build failure.
Framework-specific notes
Express.js — nothing is built in. Combine requireLogin with a policy middleware, and use scoped repository methods so unscoped fetches aren't reachable from route code. CASL works well for object-level rules.
Spring Boot — use Spring Security method security rather than checks inside the service body:
@PreAuthorize("@invoicePolicy.canRead(#id, authentication)")
public InvoiceDto getInvoice(Long id) { ... }@PreAuthorize("@invoicePolicy.canRead(#id, authentication)")
public InvoiceDto getInvoice(Long id) { ... }Or keep it in the repository: findByIdAndOrgId(id, orgId). Spring Security ACLs exist for fine-grained per-object permissions if you need them.
ASP.NET Core — resource-based authorization is the right tool, because [Authorize(Roles = "Customer")] only covers function level:
var invoice = await _db.Invoices.FindAsync(id);
var result = await _authService.AuthorizeAsync(User, invoice, "InvoiceOwner");
if (!result.Succeeded) return NotFound();var invoice = await _db.Invoices.FindAsync(id);
var result = await _authService.AuthorizeAsync(User, invoice, "InvoiceOwner");
if (!result.Succeeded) return NotFound();Django — filter the queryset, don't fetch then check:
invoice = get_object_or_404(Invoice, pk=pk, org=request.user.org)invoice = get_object_or_404(Invoice, pk=pk, org=request.user.org)In DRF, override get_queryset() to scope by the caller. Be aware that has_object_permission() is not called for list views and is not called if you fetch the object yourself instead of via get_object() that gap catches a lot of people.
Laravel — Policies plus $this->authorize('view', $invoice), or Route::resource(...)->middleware('can:view,invoice'). If you use route model binding on nested routes, add ->scopeBindings() so /orgs/{org}/invoices/{invoice} actually verifies the invoice belongs to that org. Without it, binding resolves each parameter independently.
Ruby on Rails — scope through the association, which makes the safe version also the shortest version:
@invoice = current_user.organization.invoices.find(params[:id])@invoice = current_user.organization.invoices.find(params[:id])Layer Pundit or CanCanCan on top for role-based object permissions. Pundit's verify_authorized after-action hook is useful: it turns "you forgot to authorize" into a test failure.
Catching it before production
Unit tests on the policy layer. If authorization lives in one module, you can test it exhaustively every role against every action against owned and unowned objects.
Integration tests with two users. This is the highest-value thing you can add today. Create user A and user B in your test fixtures. For every endpoint that takes an ID, assert that B gets a 404 for A's object.
A route coverage test. Enumerate your registered routes, and fail the build if any route with an :id parameter has no declared policy. This is the one that keeps working after you leave the team.
SAST. Useful for injection and hardcoded secrets, weak for IDOR unless you write custom rules for your own repository patterns. Worth doing, not worth relying on.
DAST and authenticated scanning. Configure your scanner with two accounts at the same privilege level so it can compare responses. Generic unauthenticated scans won't find this.
Code review. Genuinely effective here, but only with a checklist. See the next section.
CI/CD. Add dependency scanning and secret detection alongside the authorization tests. Fail the build; don't just produce a report nobody opens.
Questions to ask in code review
Print these. Put them in your PR template.
- Does this handler take an identifier from the client? Where does it come from?
- Is the ownership condition inside the query, or in a separate check that could be skipped?
- If I logged in as a different customer and sent this exact request, what would I get back?
- Is the tenant or user identifier read from the session, or from something the client controls?
- Are both "does this belong to them" and "are they allowed to do this" answered?
- For nested routes, is the parent–child relationship actually verified?
- Does this endpoint accept arrays or ranges? Is every element checked?
- Does the response leak fields this caller shouldn't see?
- What HTTP status comes back for someone else's object and does it confirm existence?
- Is this access logged well enough that we'd notice a hundred thousand of them?
Question 3 is the one that finds bugs. Ask it about every endpoint, out loud, before approving.
The principles underneath
Complete mediation — every access to every object gets checked, every time. Not cached, not assumed from a previous request, not delegated to the frontend. This is the principle IDOR violates by definition.
Zero trust — a valid session is a starting point, not a conclusion. Between microservices too: a request arriving from your own gateway still needs to prove what it's allowed to touch.
Fail securely — when the check can't be made or the object isn't found, deny. A route with no policy attached should refuse to serve, not serve openly.
Secure by default — make the safe call the easy call. If findById doesn't exist in your codebase and findByIdForUser does, developers can't get it wrong. This beats documentation and training every time.
Least privilege — scope tokens, sessions, and service accounts to what they actually need. It doesn't prevent IDOR, but it caps the damage when one slips through.
Defence in depth — application checks and scoped repositories and row-level security and audit logging. Any single layer will eventually have a gap.
Input validation — an ID in a URL is input. It deserves the same suspicion as a form field, even though it doesn't feel like one.
Key takeaways
- IDOR is missing authorization, not missing authentication. Login checks don't help.
- Any identifier a client can send is attacker-controlled, including URL segments and UUIDs.
- Put ownership into the
WHEREclause. A query that can't return the wrong row beats anifthat can be deleted. - Always read tenant and user identity from the session, never from the request.
- Check ownership and permission they're different questions.
- Return 404 rather than 403 for objects outside the caller's scope.
- Write endpoints are worse than read endpoints. Give them more scrutiny, not less.
- Scanners are weak here. Two-user integration tests are strong. Write them.
- The real fix is structural: one policy layer, scoped repositories, and a build that fails when a route has no declared authorization.
If you take one thing from this: open your codebase, search for findById, find(params[:id]), findUnique, or get_object_or_404, and for each result ask what stops a different customer from calling it.
You'll probably find at least one. Most teams do.
If you've fixed an IDOR that hid in your codebase for years, I'd like to hear how you found it. The detection story is usually more interesting than the fix.