Dynamic-id routes enforce object-level ownership
Why it matters
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.
Severity rationale
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.
Remediation
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.
Detection
- ID:
object-level-access-control - Severity:
critical - What to look for: Enumerate handlers with bracketed dynamic segments:
app/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 } }), rawWHERE 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 onauth.uid(). - Pass criteria: Every dynamic-id handler reading or mutating a row includes an ownership predicate OR is provably covered by RLS.
- Fail criteria: Any handler queries by
params.idonly, no ownership predicate, no RLS. A post-fetchif (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. - Skip (N/A) when: No bracketed dynamic API routes. Quote directory walk.
- Before evaluating, quote: Full handler source for each flagged route + the Prisma schema / Supabase migration showing the ownership column.
- Report even on pass: Each route + predicate used:
"app/api/orders/[id]/route.ts: .findFirst({ where: { id, userId: session.user.id } })". - Detail on fail:
"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". - Remediation:
Apply the same predicate to PATCH/PUT/DELETE. For Supabase, rely on RLS// 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); }auth.uid()policies OR add.eq('user_id', session.user.id)to every.from('...')call.
External references
- cwe · CWE-285 — Improper Authorization
- cwe · CWE-639 — Authorization Bypass Through User-Controlled Key
- cwe · CWE-862 — Missing Authorization
- owasp:2021 · A01 — Broken Access Control
- owasp:2023 · API1 — Broken Object Level Authorization
Taxons
History
- 2026-04-23·v1.0.0·Initial Phase 9.1 v3.1 Stack Scan promotion — IDOR is the·by phase-9-1-stack-scan-v3-1