Insecure Direct Object Reference (IDOR) is the most-exploited class of API vulnerability in the modern web and the top entry on the OWASP API Security Top 10 (API1:2023 — Broken Object Level Authorization). The pattern is almost always the same in AI-generated code: a route at /api/orders/[id]/route.ts reads params.id and runs db.order.findUnique({ where: { id: params.id } }) without adding a user_id = session.user.id predicate. The route authenticates the requester but never authorizes them against the resource, so any logged-in user can iterate IDs and harvest strangers' orders, messages, invoices, or medical records. Optus suffered a catastrophic 10-million-customer breach in 2022 that traced back to exactly this pattern: guessable contact IDs, no ownership predicate. Snapchat's 2014 "Find Friends" leak exposed 4.6 million phone numbers the same way. AI tools produce this reliably because the naive happy-path code ("read the row matching the URL id") works fine when the developer is the only user in the test database.
Critical because any authenticated user can trivially enumerate IDs and exfiltrate every other user's private data — no exploit chain needed, just a loop that increments the URL parameter.
Every route that reads params.id, params.userId, params.orderId, or similar URL path parameters must add an ownership predicate to the database query that ties the resource to the authenticated session user:
// app/api/orders/[id]/route.ts
export async function GET(req: Request, { params }: { params: { id: string } }) {
const session = await auth();
if (!session?.user) return new Response('Unauthorized', { status: 401 });
const order = await db.order.findFirst({
where: { id: params.id, userId: session.user.id },
});
if (!order) return new Response('Not Found', { status: 404 });
return Response.json(order);
}
For Supabase, either rely on Row-Level Security policies that filter by auth.uid(), or add .eq('user_id', session.user.id) to every .from('...') call. Apply the same ownership predicate to PATCH, PUT, and DELETE handlers — otherwise users can mutate each other's data. For a deeper IDOR sweep across your API surface, run the api-security Pro audit.
project-snapshot.security.object-level-access-controlcriticalapp/api/**/[id]/**/route.ts, app/api/**/[*Id]/**/route.ts, pages/api/**/[id].ts. For each that reads a DB row (.findUnique, .findFirst, .findOne, .get, .select, .from('...').eq('id', ...), findUnique({ where: { id } }), raw WHERE id = $1), check whether the query also includes an ownership predicate tied to the session user: .eq('user_id', session.user.id), where: { userId: session.userId }, WHERE org_id = $2, or a Supabase RLS policy on auth.uid().params.id only, no ownership predicate, no RLS. A post-fetch if (row.userId !== session.user.id) check WITHOUT a 403/404 return does NOT count — if the row was ever returned, it's still IDOR. Ownership field read from the request body (trivially spoofable) does NOT count."app/api/orders/[id]/route.ts: .findFirst({ where: { id, userId: session.user.id } })"."app/api/messages/[id]/route.ts GET runs db.message.findUnique({ where: { id: params.id } }) with no user predicate — any logged-in user can iterate IDs and read others' messages".// app/api/orders/[id]/route.ts
export async function GET(req: Request, { params }: { params: { id: string } }) {
const session = await auth();
if (!session?.user) return new Response('Unauthorized', { status: 401 });
const order = await db.order.findFirst({ where: { id: params.id, userId: session.user.id } });
if (!order) return new Response('Not Found', { status: 404 });
return Response.json(order);
}
Apply the same predicate to PATCH/PUT/DELETE. For Supabase, rely on RLS auth.uid() policies OR add .eq('user_id', session.user.id) to every .from('...') call.