IDOR in API routes: how one user reads another user's orders
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.
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:
- List endpoints:
findMany()with nowhere, or auserIdtaken from the query string, hands over everyone's rows. - Nested resources:
/api/projects/[projectId]/tasks/[taskId]checks the project but loads the task by id alone. - Writes: PATCH and DELETE routes often skip the check even when GET has one.
- Ids in the body: a hidden
user_idfield in a form is a reference the caller controls. - Files: a filename is a reference too. Swap it for another valid one and you may read someone else's file.
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.
// 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)
}// 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)
}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 Found | 403 Forbidden | |
|---|---|---|
| What the caller learns | Nothing. Same answer as a missing id | The object exists but isn't theirs |
| Fits a scoped query (WHERE id AND user_id) | Yes, no extra code | Needs a second lookup |
| Good default for | Orders, documents, profiles | Shared 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.
// 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)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 })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.
List every route that takes an id
Search for every dynamic segment (any folder name in square brackets, such as
[id]) underapp/, plus server actions and any handler that reads an id from the query string or body. Include PATCH, PUT and DELETE, not just GET.Get the user from the session
Read the user on the server and return 401 if there isn't one. Never accept
userIdfrom the URL, query or body for ownership. If your auth is Clerk or Auth.js, see Clerk and NextAuth mistakes.Put the owner in every query
Use
where: { id, userId: user.id }for reads. UseupdateManyanddeleteManywith the same condition for writes. Scope list endpoints to the signed-in user.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.Allow-list the body
Parse input with a schema that names the editable fields. Set
userId,role,statusand prices on the server.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.
- Create user A and user B. As A, create an order, a project and a document. Note their IDs.
- 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.
- 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.
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
- OWASP: Insecure Direct Object Reference Prevention Cheat Sheet
- PortSwigger Web Security Academy: Insecure direct object references (IDOR)
- OWASP Top 10:2025, A01 Broken Access Control
- 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 repoLeakcade 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