# Dynamic-id routes enforce object-level ownership

- **Pattern:** `ab-002612` (`project-snapshot.security.object-level-access-control`)
- **Severity:** critical
- **Lifecycle:** active
- **Last modified:** 2026-04-23
- **Canonical URL:** https://auditbuffet.com/patterns/ab-002612
- **License:** CC-BY-4.0 — attribute to AuditBuffet Pattern Catalog (https://auditbuffet.com/patterns/ab-002612)

## 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:
  ```ts
  // 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 } })`, 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()`.
- **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.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.
- **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:**
  ```ts
  // 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.

## External references

- cwe CWE-285
- cwe CWE-639
- cwe CWE-862
- owasp A01
- owasp API1

Taxons: project-snapshot, access-control

HTML version: https://auditbuffet.com/patterns/ab-002612
