LEAKCADE

IDOR in API routes: how one user reads another user's orders

Updated 8 min read

IDOR (insecure direct object reference) means your API returns a record because the ID matches, without checking the owner. Change /api/orders/123 to 124 and you read someone else's order. AI tools often write 'find by id' queries. The fix: add the signed-in user's id to the query next to the record id. Apply it to every read, write, list and nested route, and never take the owner from the body.

QUICK CHECKLIST

  • Put the owner id in every query: WHERE id AND user_id
  • Take the user id from the session, never from the request
  • Scope list endpoints to the signed-in user
  • Check the whole chain on nested routes
  • Allow-list body fields, never pass req.json() to the database
  • Test with two accounts, on read, update and delete

What IDOR is and why AI-built APIs have it

An insecure direct object reference is a missing ownership check. OWASP names three ingredients: an object (an order, a document), a reference to it (an ID, a UUID, which is a long random identifier, or a filename), and no check that the caller is allowed to touch that object.

Being logged in answers one question: who is this? It doesn't answer whether row 123 belongs to them.

The shortest query for a GET /api/orders/[id] route is 'find by id', and it works in your demo because you're the only user. OWASP's Top 10:2025 keeps broken access control at number one, with insecure direct object references listed under it.

Find by id vs owner in the queryBob requests Alice's order by ID. With a find-by-id query the API returns Alice's data. With the owner in the query the database finds no row and the API answers 404.BEFORE: FIND BY ID ONLYGET /api/orders/123WHERE id = 123Alice's order row200 + Alice's dataAFTER: OWNER IN THE QUERYGET /api/orders/123id = 123 AND user_id = BobNo matching row404 Not foundBOBBoblogged inAPIOrders routeby id routeDBorders tableevery userBEFORE: FIND BY ID ONLYGET/api/orders/123WHERE id = 123Alice's orderrow200 + Alice'sdataAFTER: OWNER IN THE QUERYGET/api/orders/123id = 123 ANDuser_id = BobNo matching row404 Not foundBOBBoblogged inAPIOrders routeby id routeDBorders tableevery user
Bob is logged in both times. The owner condition in the second query is what returns 404.

How someone abuses an IDOR in your API

Bob signs up for your app and places an order. He opens DevTools, sees the request go to /api/orders/123, and changes the number. If your route only finds by id, he gets Alice's order, and with sequential IDs he can loop through all of them.

The same hole shows up in more places than the single-record GET:

Fix IDOR with an owner check in Prisma, Drizzle and Supabase

OWASP's pattern is to look objects up in a dataset the user can access. In Rails that is current_user.projects.find(id). In an ORM (the library that builds your database queries, such as Prisma or Drizzle) it means the owner is part of the where, so a record that isn't yours isn't found.

BEFOREWrong: find by id only
// app/api/orders/[id]/route.ts
import { prisma } from '@/lib/prisma'
import { getSessionUser } from '@/lib/auth'

export async function GET(
  _req: Request,
  { params }: { params: Promise<{ id: string }> }
) {
  const user = await getSessionUser()
  if (!user) return Response.json({ error: 'Unauthorized' }, { status: 401 })

  const { id } = await params
  const order = await prisma.order.findUnique({ where: { id } }) // any owner
  if (!order) return Response.json({ error: 'Not found' }, { status: 404 })
  return Response.json(order)
}
AFTERRight: owner is part of the query
// app/api/orders/[id]/route.ts
import { prisma } from '@/lib/prisma'
import { getSessionUser } from '@/lib/auth'

export async function GET(
  _req: Request,
  { params }: { params: Promise<{ id: string }> }
) {
  const user = await getSessionUser()
  if (!user) return Response.json({ error: 'Unauthorized' }, { status: 401 })

  const { id } = await params
  // Int or UUID ids: validate first, return 404 if invalid
  const order = await prisma.order.findFirst({
    where: { id, userId: user.id }, // only this user's orders
  })
  if (!order) return Response.json({ error: 'Not found' }, { status: 404 })
  return Response.json(order)
}
Both versions require a login. Only the second adds `userId` to the query.

getSessionUser stands for whatever your auth library gives you on the server (Clerk, Auth.js, Supabase).

The id from the URL is a string. If your column is an integer or a UUID, validate and convert it first, and return 404 when invalid, or a malformed id can make the route return 500.

Drizzle is the same idea:

import { and, eq } from 'drizzle-orm'

const [order] = await db
  .select()
  .from(orders)
  .where(and(eq(orders.id, id), eq(orders.userId, user.id)))
  .limit(1)

if (!order) return Response.json({ error: 'Not found' }, { status: 404 })

With Supabase, the owner filter is a security check only when the query runs on the server. If the Supabase client runs in the browser, anyone can remove the filter, so you need row level security (RLS), the database rules that limit which rows each user can see. In server code, add the filter too, in case someone later switches to the service role key:

const { data: order } = await supabase
  .from('orders')
  .select('*')
  .eq('id', id)
  .eq('user_id', user.id)
  .maybeSingle()

if (!order) return Response.json({ error: 'Not found' }, { status: 404 })

Writes need the same condition. Use updateMany or deleteMany with the owner in the where, then check the count:

const { count } = await prisma.order.updateMany({
  where: { id, userId: user.id },
  data: { note: input.note },
})
if (count === 0) return Response.json({ error: 'Not found' }, { status: 404 })

List endpoints get where: { userId: user.id } and nothing from the query string. For nested routes, filter through the parent so one query checks the whole chain:

const task = await prisma.task.findFirst({
  where: { id: taskId, projectId, project: { userId: user.id } },
})

I'd return 404 for 'not found' and 'not yours' alike. OWASP says that if the existence of a resource is sensitive, use the scoped lookup and map both cases to the same response.

404 Not Found403 Forbidden
What the caller learnsNothing. Same answer as a missing idThe object exists but isn't theirs
Fits a scoped query (WHERE id AND user_id)Yes, no extra codeNeeds a second lookup
Good default forOrders, documents, profilesShared items where members know it exists

Mass assignment: never take user_id from the request body

Mass assignment is the write-side twin of IDOR: your handler passes the whole JSON body to the database, so the caller decides which fields get set. OWASP notes that attackers can guess field names without seeing your source code, and it lists the 2012 GitHub hack as a case of mass assignment.

BEFOREWrong: the body decides every field
// POST /api/orders
const body = await req.json()

const order = await prisma.order.create({
  data: body, // body can contain userId, status, anything
})
return Response.json(order)
AFTERRight: schema in, owner from session
import { z } from 'zod'

const CreateOrder = z.object({
  productId: z.string(),
  quantity: z.number().int().min(1).max(10),
})

const user = await getSessionUser()
if (!user) return Response.json({ error: 'Unauthorized' }, { status: 401 })

const parsed = CreateOrder.safeParse(await req.json())
if (!parsed.success) {
  return Response.json({ error: 'Invalid input' }, { status: 400 })
}

const order = await prisma.order.create({
  data: { ...parsed.data, userId: user.id }, // trusted value comes last
})
return Response.json(order, { status: 201 })
The schema drops unknown keys, and userId always comes from the session.

Watch the order if you spread the body. { userId: user.id, ...body } is still wrong, because the body's userId overwrites yours. Putting trusted values last only protects keys you remember to override, so validate the body with an allow-list schema first.

OWASP also warns that field allow-lists don't replace authorization for the object itself. An update route needs both: a schema and the owner condition in the where. Server actions take ids and form data from the client too, so apply both there.

How to fix IDOR, step by step

Check each route, because one route without the check exposes data.

  1. List every route that takes an id

    Search for every dynamic segment (any folder name in square brackets, such as [id]) under app/, plus server actions and any handler that reads an id from the query string or body. Include PATCH, PUT and DELETE, not just GET.

  2. Get the user from the session

    Read the user on the server and return 401 if there isn't one. Never accept userId from the URL, query or body for ownership. If your auth is Clerk or Auth.js, see Clerk and NextAuth mistakes.

  3. Put the owner in every query

    Use where: { id, userId: user.id } for reads. Use updateMany and deleteMany with the same condition for writes. Scope list endpoints to the signed-in user.

  4. Check the whole chain on nested routes

    Filter through the parent, like project: { userId: user.id }. Don't check the parent and then load the child by id alone.

  5. Allow-list the body

    Parse input with a schema that names the editable fields. Set userId, role, status and prices on the server.

  6. Return the same 404

    Answer 404 whether the id doesn't exist or belongs to someone else.

Test it yourself

OWASP recommends testing with two accounts. These steps are for your own app and accounts you created. Don't point them at anyone else's.

  1. Create user A and user B. As A, create an order, a project and a document. Note their IDs.
  2. Log in as B in a private window. Open DevTools, go to the Network tab, find a request to your API and use Copy as cURL.
  3. Replace the ID with A's ID and send it:
# Your own app, your own test accounts only
curl -i http://localhost:3000/api/orders/ORDER_ID_OF_USER_A \
  -H 'Cookie: YOUR_USER_B_SESSION_COOKIE'

A bad result is 200 with A's order in the JSON. A good result is 404 (or 403 if you chose that). Repeat with PATCH and DELETE, then check that A's order didn't change.

Next, check the list endpoint and the request body:

# B's list should only contain B's orders
curl -s http://localhost:3000/api/orders -H 'Cookie: YOUR_USER_B_SESSION_COOKIE'

# B tries to create an order owned by A
curl -i -X POST http://localhost:3000/api/orders \
  -H 'Content-Type: application/json' \
  -H 'Cookie: YOUR_USER_B_SESSION_COOKIE' \
  -d '{"productId":"prod_1","quantity":1,"userId":"USER_A_ID"}'

If the list shows A's rows, or the new order belongs to A, you have a problem. Also check that a request with no cookie gets 401.

Run this check for every operation that uses an object reference, including export. Once it passes by hand, automate it so a future AI edit can't undo it unnoticed.

Fix prompt you can paste

Give this to Cursor or Claude Code. It asks for file and line numbers so you can verify each finding.

FIX PROMPT
Audit every API route handler (app/api/**/route.ts), server action and
Prisma/Drizzle/Supabase query in this repo for IDOR and mass assignment.

1. List every route or action that accepts an id (path param, query string,
   body or FormData).
2. For each one, show whether the query includes the signed-in user's id
   taken from the server session, not from the request. Flag any
   findUnique/findFirst/findMany/update/delete/select that filters only by
   id, or has no where clause on a user-owned table.
3. Flag nested routes where only the parent is checked, list endpoints with
   no user filter, file download or export routes that take a filename or
   id, and any query made with the Supabase service role key or an admin
   client (RLS does not apply there).
4. Flag any place that passes req.json() or formData straight into
   create/update, or reads userId/user_id/ownerId/role from the body or query.

Before changing anything, output a list of file:line for each finding with
the route and the missing condition. Then fix: add the owner condition
(where: { id, userId: user.id }), use updateMany/deleteMany and return 404
when count is 0, validate bodies with a zod schema that lists only the
allowed fields, and set userId from the session. Return the same 404 for
missing and not-yours.

Then show the diff and explain each change in one sentence. Do not change
the auth library or the database schema. Add a test with two users proving
user B gets 404 on user A's order.

After the AI finishes, run the two-account test above anyway. If you want a second pair of eyes on your repo or live URL, Leakcade can scan it.

Further reading

  1. OWASP: Insecure Direct Object Reference Prevention Cheat Sheet
  2. PortSwigger Web Security Academy: Insecure direct object references (IDOR)
  3. OWASP Top 10:2025, A01 Broken Access Control
  4. OWASP: Mass Assignment Cheat Sheet

FAQ

What is IDOR in simple terms?

IDOR is when a user can read or change someone else's data by changing an ID in a URL, query or request body. It happens because the server finds the record but never checks that it belongs to the caller.

Do UUIDs prevent IDOR?

No. UUIDs make IDs hard to guess, and OWASP suggests them as an extra layer. OWASP also says access checks are still essential, because a leaked UUID works for anyone if the route doesn't check ownership.

404 vs 403: which should I return for someone else's record?

For private user data, return 404. A scoped query like WHERE id AND user_id gives you this naturally, and the caller can't tell a missing record from someone else's.

How do I test my Next.js app for IDOR?

Create two test accounts and make records as user A. Then, logged in as user B, request A's IDs with GET, PATCH and DELETE and check the list endpoints. Anything other than 404 or 403, or any of A's rows in B's list, is a bug. Only do this on your own app.

What is mass assignment and how does it relate to IDOR?

Mass assignment is when a handler passes the request body straight into a database write, so the caller can set fields like userId or role. It lets a user create or move records into another account, which has the same effect as an IDOR. Validate the body with an allow-list schema and set owner fields from the session.

Want a second pair of eyes?

We review your whole repo and write a fix prompt for every finding. $19, report within an hour.

Check my repo

Leakcade is an automated check. It can miss vulnerabilities and is not a substitute for a professional security audit or pentest. Provided as is. You stay responsible for your code. Where the law allows, our liability is limited to what you paid ($19). Terms

Patch Notes by Leakcade

A short weekly email: what changed in AI coding tools, one real security mistake, one prompt you can reuse.