What Is IDOR? The Security Bug Where Users Can See Each Other's Data
IDOR (insecure direct object reference) is when changing an ID in a URL shows you someone else's data. How it happens, why it's the most common serious bug in AI-built apps, how to test for it in five minutes, and the one-line habit that prevents it.
Log in to an app, open one of your invoices, and look at the address bar:
https://app.example.com/invoices/1042
Now change 1042 to 1041. If you see someone else's invoice, you've found an IDOR — an insecure direct object reference. It's one of the simplest security bugs to exploit and one of the most common in apps built quickly, especially with AI tools.
What IDOR means
- Direct object reference: the app uses an identifier — an invoice ID, user ID, file name — directly in a URL or request to fetch something.
- Insecure: the app never checks whether the person asking is allowed to have that particular thing.
It falls under "Broken Access Control", number one on the OWASP Top 10.
Why it happens
The app checks authentication — "are you logged in?" — but not authorization — "is this yours?" (Authentication vs authorization explains the difference.)
Here's a typical vulnerable API route:
// GET /api/invoices/:id
app.get("/api/invoices/:id", requireLogin, async (req, res) => {
const invoice = await db.invoice.findUnique({
where: { id: req.params.id },
});
res.json(invoice);
});
requireLogin makes sure someone is logged in. Then it returns whichever invoice was asked for. Any logged-in user can read every invoice by counting up the IDs.
AI-generated code does this a lot, because "fetch the thing with this ID" is the obvious, working version — and it passes every test where you only look at your own data.
The fix: scope every query to the user
Include the current user in the lookup:
app.get("/api/invoices/:id", requireLogin, async (req, res) => {
const invoice = await db.invoice.findFirst({
where: { id: req.params.id, userId: req.user.id },
});
if (!invoice) return res.status(404).json({ error: "Not found" });
res.json(invoice);
});
Now another user's invoice simply isn't found. Return 404 rather than 403, so you don't confirm the record exists.
Crucially, req.user.id comes from the server-side session, never from the request body or URL. If the browser can send userId, an attacker can send someone else's.
It's not just URLs
The same bug appears anywhere an ID comes from the user:
- Updates and deletes:
PATCH /api/profilewith{ "userId": 77, "email": "..." }— changing someone else's account. - Hidden form fields:
<input type="hidden" name="accountId" value="5">. - File downloads:
/files/download?name=contract-1042.pdf. - GraphQL and RPC calls that take IDs as arguments.
- Database-direct apps: in Supabase, the equivalent is a table without row-level security — anyone can query any row. (Supabase RLS explained.)
- AI tools: a chatbot tool like
get_order(order_id)must check the order belongs to the logged-in user. (Function calling.)
"But my IDs are random UUIDs"
Unguessable IDs make IDOR harder to exploit, not impossible. IDs leak — in emails, shared links, logs, screenshots, browser history. Random IDs are a nice extra layer; the ownership check is the real fix. (UUID vs auto-increment.)
Test your app in five minutes
- Create two accounts, A and B, in two different browsers (or one normal and one private window).
- As A, create some data: an invoice, a note, a file. Note its ID in the URL or the Network tab of DevTools.
- As B, try to view, edit and delete A's item by putting A's ID in the URL — or by replaying the API request with A's ID.
- Try the same for every type of object your app has.
Anything that works for B is an IDOR. Fix it before launch.
A habit that prevents it
- Every query that fetches user data includes the user (or their team/organisation) in the
WHEREclause. - Put that in your
CLAUDE.mdas a rule for AI tools: "Every database query for user-owned data must be scoped to the current user from the session." (How to write a CLAUDE.md.) - For multi-user organisations, scope by organisation and check the user's role. (Role-based access control.)
- Use database-level enforcement (row-level security) as a second layer where you can.
The summary
- IDOR = changing an ID gives you someone else's data.
- It happens when an app checks login but not ownership.
- Fix: scope every lookup to the current user from the server-side session; return 404.
- Test with two accounts before launch.
EasySpawn gives Claude Code a real running app and database to test against — including logging in as two different users and checking one can't reach the other's data. See how it works or join the waitlist.
Related: Vibe Coding Security Checklist · How to Review a Pull Request Written by an AI Agent · Multi-Tenant SaaS on Postgres · REST API Design
Keep reading
Do You Need a Cookie Banner? A Plain-English Guide for Small Apps
Cookie banners are required when you set non-essential cookies or trackers for EU and UK visitors — not for every cookie. What counts as essential, when you need consent, what a compliant banner must do, and how to avoid needing one at all.
Two-Factor Authentication Explained (and How to Add It to Your App)
What two-factor authentication is, how authenticator-app codes (TOTP) work, why SMS codes are the weakest option, where passkeys fit, and how to add 2FA to your own app — including recovery codes and the mistakes to avoid.