The Most Common API Bug I See in Review: IDOR
Insecure direct object references are boring, ancient, and still everywhere. Why find(params[:id]) needs a scope, and how to review for object-level authorization.
Open the developer tools on almost any web app, watch the network tab, and you'll see requests like GET /api/invoices/48213. Now here's the question I ask in every API review, and I mean every single one: what happens if I change 48213 to 48214?
If the answer is "you get someone else's invoice," congratulations, you've found an IDOR — an insecure direct object reference. It's the bug OWASP has ranked at or near the top of its API security list for years under the name Broken Object Level Authorization, and in my experience it's the single most common vulnerability that makes it through code review. Not because it's clever. Because it's invisible. The vulnerable code looks exactly like correct code.
Why the vulnerable version looks fine
Here's the shape of the bug in Rails, though every framework has its dialect:
class InvoicesController < ApplicationController
before_action :authenticate_user!
def show
@invoice = Invoice.find(params[:id])
render json: @invoice
end
end
This code authenticates. It uses the ORM correctly. It handles the record-not-found case the framework way. A reviewer skimming for "does this look like idiomatic Rails" nods and moves on. But Invoice.find answers the question "does invoice 48214 exist?" when the question that matters is "does invoice 48214 belong to this user?"
Authentication tells you who someone is. Authorization tells you what they're allowed to touch. IDOR lives in the gap between those two, and the gap is easy to miss because the happy path — a user clicking links in their own UI — never exercises it. Your own frontend will only ever send IDs the user legitimately owns. The person editing the URL by hand is the only one who tests the interesting case, and they don't file bug reports.
The fix is a scope:
def show
@invoice = current_user.invoices.find(params[:id])
render json: @invoice
end
Now the lookup itself encodes the authorization. If the invoice exists but belongs to someone else, the query simply doesn't find it, and the attacker gets the same 404 they'd get for a nonexistent ID — which is exactly what you want, because a 403 confirms the resource exists and invites more poking.
Where IDOR hides
The show action is the textbook case, but after enough reviews you learn its favorite hiding spots.
Writes, not just reads
Reading someone else's invoice is bad. PATCH /api/invoices/48214 with a body of {"status": "paid"} is worse. Update and destroy actions need the same scoped lookup as reads, and they're more often missed because the reviewer's attention goes to the parameter handling, not the fetch.
Nested and indirect references
The route might scope the top-level resource correctly and then trust an ID buried in the request body: POST /api/payments with {"invoice_id": 48214}. Every foreign key a client can supply is a direct object reference, whether or not it appears in the URL. I read request bodies with the same suspicion I read routes.
"Unguessable" IDs
Teams sometimes swap sequential integers for UUIDs and call the problem solved. Random IDs genuinely raise the cost of blind enumeration, and I do recommend them — but as defense in depth, not as authorization. IDs leak: into emails, logs, browser history, support tickets, screenshots pasted into Slack. Once an attacker has a valid ID from any of those channels, an unguessable ID that isn't authorization-checked is just a long password everyone keeps writing down.
Endpoints that return lists
GET /api/invoices?user_id=7 is IDOR with extra steps. If the server takes user_id from the query string instead of from the session, changing one digit hands over someone else's entire collection. Filter parameters that select whose data comes back should come from the authenticated context, never from the client.
Making the safe thing the default thing
Individual vigilance doesn't scale; I've missed these myself on a Friday afternoon. What scales is structure that makes the unsafe version look weird.
The first move is convention: in controllers, unscoped Model.find is treated as a smell by default. Some teams enforce this with a custom lint rule; others just make current_user.things.find the pattern every example in the codebase uses, so the unscoped call stands out in a diff like a missing test file. If you use a policy library — Pundit, CanCanCan, or your framework's equivalent — the key discipline is that policies run on the instance, not just the action. "Can this user view invoices?" is the wrong question. "Can this user view this invoice?" is the right one.
The second move is testing the unhappy path on purpose. For every resource endpoint, I want at least one test where user A requests user B's object and gets a 404. It's four lines of test code, and it converts object-level authorization from a review-time hope into a regression suite guarantee.
The AI wrinkle
I'll flag one trend from recent reviews: AI coding assistants generate the unscoped version constantly, because the unscoped version is the most statistically common pattern in every tutorial ever written. Ask an assistant to "add an endpoint to fetch a document by ID" and you will get Document.find(params[:id]) with tidy error handling and maybe even a test — for the happy path. It looks finished. It reads as competent. The authorization is simply absent, and absence is the hardest thing to spot in review because there's no bad line to point at.
My habit now: when a diff contains a generated-looking endpoint, I explicitly ask "where is the ownership check?" before anything else. If I can't answer it in ten seconds, that's the review comment.
Questions I ask on every endpoint diff
- If I change the ID in this request to someone else's, what exactly happens? (Trace it — don't assume.)
- Is every lookup scoped to the current user, org, or tenant — including in update and destroy?
- Are there object IDs in the request body that get trusted without an ownership check?
- Do list endpoints derive "whose data" from the session rather than a client parameter?
- Is there a test where the wrong user asks for this object and is refused?
IDOR isn't a hard bug to fix. It's a hard bug to notice, because the vulnerable code is a subset of correct code with one clause missing. Train your eye to look for the scope, not the syntax, and you'll start catching the most common API vulnerability in the wild before it ships.