All checks

IDOR (insecure direct object reference)

Severity: High – CriticalQuick + Deep· 8 min read

What it is

An insecure direct object reference (IDOR) happens when your app loads a record using an ID that came from the user, and never checks that the record belongs to them. The user is logged in, so the request looks legitimate. But change /api/orders/41 to /api/orders/42 and they get someone else's order.

Think of a hotel where every room key opens every door. The front desk checks that you're a guest, which is authentication. Nobody checks that room 42 is your room, which is authorization. IDOR is the missing second check.

Why it matters

IDOR is quiet and easy to find. There's no error, no crash, nothing unusual in your logs except a user reading records in order. The data that leaks is whatever your app stores per user: invoices, messages, addresses, uploaded files, medical notes.

It also scales. If IDs are numbers that count up, one script can walk the whole table. In 2019, a security researcher reported that First American Financial exposed roughly 885 million records, including bank account numbers and Social Security numbers, because documents were served from sequential links with no check on who was asking.

Broken access control sits at the top of the OWASP Top 10, and broken object level authorization is the first item on the OWASP API Security Top 10. It's the most common way a working app leaks data between its own users.

How it happens

The pattern is always the same: a route takes an ID, loads the record by that ID alone, and returns it. Login middleware is often in place, which makes the code look protected.

// GET /api/orders/:id  (Express, user is logged in)app.get("/api/orders/:id", requireLogin, async (req, res) => {  const order = await db.order.findUnique({    where: { id: Number(req.params.id) },  });  if (!order) return res.status(404).end();  res.json(order);});

Each handler knows who the user is (requireLogin, current_user, the session). None of them uses that knowledge when it loads the order. The same mistake shows up in update and delete routes, where it's worse: one user can change or remove another user's data.

AI coding tools produce this pattern often, because "load the record by ID" is the shortest correct-looking answer to "add an endpoint that returns an order".

How it's abused

This is the attacker's view, at a conceptual level, so you know what to defend against:

  1. They sign up for a normal account and use the app as intended.
  2. They watch the requests the app makes and notice IDs in URLs or request bodies: /orders/41, ?invoice=1088, {"projectId": "p_7Hk..."}.
  3. They repeat one of those requests with a different ID. If it returns data, the route trusts the ID.
  4. With predictable IDs, they automate it and collect every record. With random IDs, they look for places where other users' IDs leak: shared links, public profiles, list endpoints, email notifications.

Random IDs such as UUIDs make step 4 harder, but they don't fix IDOR. An ID that leaks once is enough, and the route still hands the record to anyone who asks.

How to fix it

Make ownership part of the lookup. Ask the database for "order 42 that belongs to this user", not "order 42". If the user doesn't own it, the query finds nothing and you return 404, which also avoids confirming that the record exists.

app.get("/api/orders/:id", requireLogin, async (req, res) => {  const order = await db.order.findFirst({    where: { id: Number(req.params.id), userId: req.user.id },  });  if (!order) return res.status(404).end();  res.json(order);});

Why this works: the user ID comes from the server-side session, which the client can't change. The record ID still comes from the request, but it can only ever select a row the user already owns.

A few patterns make this hard to forget:

  • Scope through the user. Many ORMs let you start from the user: user.orders.find(id) in Rails, current_user.orders in Django-style code, a relation filter in Prisma.
  • Use a policy layer. If records are shared (teams, organizations), put the rule in one place, such as a canView(user, order) function or your framework's policy feature, and call it in every handler.
  • Let the database enforce it. With Supabase or Postgres, Row Level Security policies can tie every row to auth.uid(), so even a forgotten filter can't leak data. See Supabase RLS misconfiguration.
  • Cover writes too. Update and delete routes need the same ownership check as reads.

Common mistakes

  • Checking ownership after loading, then forgetting to stop. Code that loads the record, compares order.userId with the session, logs a warning and carries on still leaks the data. Return early, or better, put the check in the query.
  • Trusting a user ID from the request. where: { id, userId: req.body.userId } looks scoped, but the client chooses userId. It must come from the session.
  • Protecting the list but not the detail. GET /orders is filtered by user, while GET /orders/:id isn't. Attackers go straight to the detail route.
  • Forgetting nested routes. /projects/:projectId/files/:fileId needs to check that the project belongs to the user and that the file belongs to the project.

Testing your fix

Create two test accounts, A and B. As A, create a record and note its ID. Sign in as B and request that ID on every route that accepts it: read, update, delete and any export. Each request should return 404 and change nothing. Keep this as an automated test, because a route added next month can reintroduce the bug.

How SetRepo detects it

SetRepo builds a route inventory of your server: every endpoint, its HTTP method and its auth guards. Route extraction covers JavaScript and TypeScript, Python, Go, Java and Kotlin, PHP, Ruby, Rust and C#. Then, for every guarded route that takes an object ID, it reads the handler and looks for a lookup driven by that ID.

  • Quick and Deep scans: if the handler loads a record by the ID and never refers to the current user, SetRepo reports it with the route, the file and the line. When it isn't certain, the finding is labeled "verify" rather than presented as definite.
  • Deep scans: handlers that fall in between go to the AI review. That includes cases where the user is referenced but not clearly tied to the query, where the lookup lives in a helper, or where the data goes through Supabase or Firebase. The review reads the handler, the helpers it calls and the models it touches, then confirms or dismisses each one.
  • No silent gaps: routes or frameworks the inventory couldn't analyse are listed in the report, so you know what wasn't covered.

Each finding comes with a fix prompt you can paste into your editor.

Checklist

  • Every route that takes an object ID checks that the record belongs to the current user (or their team).
  • The user ID comes from the server-side session, never from the request.
  • Update and delete routes have the same check as read routes.
  • Records that don't belong to the user return 404, not 403.
  • Shared access (teams, organizations) goes through one policy function.
  • If you use Supabase or Postgres, Row Level Security is on for every table with user data.
  • Tests include "user B requests user A's record" and expect a 404.

FAQ

Do UUIDs fix IDOR?

No. They make IDs hard to guess, which slows down bulk scraping, but any ID that leaks (in a shared link, a log or another endpoint's response) still works. The fix is the ownership check.

Should I return 403 or 404?

Usually 404. A 403 confirms the record exists, which tells an attacker that the ID is valid. Return 403 only when the user is allowed to know the record exists.

My app has login on every route. Am I covered?

Not by itself. Login proves who the user is. IDOR is about whether that user may see this particular record, which login doesn't check.

Does this apply to GraphQL and server actions?

Yes. Any resolver, server action or RPC that accepts an ID and loads a record needs the same ownership check.

References

Check your app for this, free.

Start free scan