Blog
4 min read

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/profile with { "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

  1. Create two accounts, A and B, in two different browsers (or one normal and one private window).
  2. As A, create some data: an invoice, a note, a file. Note its ID in the URL or the Network tab of DevTools.
  3. 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.
  4. 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 WHERE clause.
  • Put that in your CLAUDE.md as 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