Next.js API Route Authorization: The IDOR Bug Your AI Leaves in Every /api/[id] Route (With Code)

July 25, 2026 · 2064 words

Next.js API route authorization has a trap that bites almost every vibe coder: your AI writes an endpoint like GET /api/orders/[id], adds a login check, and calls it done. But "logged in" is not the same as "allowed to see this order." If the handler fetches the record by the id in the URL without checking who owns it, then any logged-in user can read — or edit, or delete — anyone else's data just by changing one number in the address bar. This guide shows the exact insecure pattern AI assistants generate, why it's dangerous, and the fixed code you can paste in today.

This is not a rare edge case. It's the #1 risk on the OWASP API Security Top 10, where it's called Broken Object Level Authorization (BOLA) — the API world's name for the classic IDOR bug. OWASP notes these appear in roughly 40% of API attacks. And it hits real, funded products: in January 2024, the popular auth library Clerk shipped a critical (CVSS 9.0) IDOR fix in @clerk/nextjs, CVE-2024-22206, where a logic flaw in auth() and getAuth() let attackers reach data they shouldn't. If a dedicated auth vendor can ship this bug, the endpoint your AI wrote at 1 a.m. almost certainly has it.

What is an API route — and what is IDOR?

A Route Handler (an "API route") is a file in your Next.js app like app/api/orders/[id]/route.ts. It runs on the server and answers HTTP requests. The [id] part is a dynamic segment — whatever the caller puts in the URL (/api/orders/42, /api/orders/43) arrives in your code as params.id. Your app's own front-end calls these routes to load and save data.

Here's the catch: an API route is a public door on the internet. Your React UI is one client, but it is not the only client. Anyone with a browser, curl, or the DevTools Network tab can call the same URL with any id they like.

IDOR — Insecure Direct Object Reference — is what happens when the server hands back an object based purely on the ID in the request, without asking "does the person making this request actually own this object?" Change 42 to 43, get someone else's order. The formal weakness is CWE-639, "Authorization Bypass Through User-Controlled Key." In plain terms: the ID is the key, the user controls the key, and nothing stops them from trying every key.

A useful way to hold the two ideas apart:

  • Authentication = "Who are you?" (Are you logged in at all?)
  • Authorization = "Are you allowed to touch this specific thing?"

AI assistants are great at the first and routinely forget the second.

Diagram of a Next.js API route IDOR attack: user A is logged in but requests /api/orders/43, and because the handler only checks login and not ownership, it returns user B's private order

The code your AI wrote

Ask Cursor, Claude Code, or Copilot for "a Next.js API route to get an order by ID" and you'll very often get something like this:

// app/api/orders/[id]/route.ts — the version your AI wrote (INSECURE)
import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@/lib/auth'
import { db } from '@/lib/db'

export async function GET(
  req: NextRequest,
  { params }: { params: Promise<{ id: string }> }
) {
  // "Is anyone logged in?" — and that's the ONLY check
  const session = await auth()
  if (!session?.user) {
    return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
  }

  const { id } = await params
  const order = await db.order.findUnique({ where: { id } })

  return NextResponse.json(order)
}

It looks responsible — there's an auth check right at the top! It passes every test you run, because when you click around your own app, you only ever request your own orders. Shipped.

Why this is dangerous

The login check creates a false sense of safety. Look at what it actually verifies: that a user is signed in. It says nothing about which records that user is allowed to see. The query db.order.findUnique({ where: { id } }) fetches the order by ID and nothing else — no userId filter, no ownership comparison.

So any logged-in user (free tier, trial account, a competitor who signed up this morning) can do this:

# Attacker is logged in as themselves, but requests an ID that isn't theirs
curl "https://your-app.vercel.app/api/orders/43" \
  -H "Cookie: session=<attacker's-own-valid-session>"

They walk /api/orders/1, /2, /3… and scrape every order in your database — names, addresses, totals, whatever the record holds. The same hole in a PATCH or DELETE handler means they can edit or destroy other people's data, not just read it. This is exactly the class of bug the Clerk CVE-2024-22206 advisory was rated critical for, and it's the same broken-access-control family as the exposed-database problem we covered in our Supabase RLS guide.

One more subtle trap: many AI-generated apps put a login gate in middleware.ts and assume that's enough. Middleware only decides whether the request reaches the route — it has no idea which order ID is being requested, so it can never stop an IDOR. The check has to happen next to the data.

The fix: check ownership, not just login

The rule from OWASP is blunt: "Every API endpoint that receives an ID of an object, and performs any type of action on the object, should implement object level authorization checks." In practice that means one extra condition: the record must belong to the current user. There are two clean ways to enforce it.

Option A — filter by owner in the query. Instead of "find this order," ask "find this order that belongs to me." If it isn't theirs, the query returns nothing and you send a 404:

// app/api/orders/[id]/route.ts — the fixed version (SECURE)
import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@/lib/auth'
import { db } from '@/lib/db'

export async function GET(
  req: NextRequest,
  { params }: { params: Promise<{ id: string }> }
) {
  // 1. Authentication — is anyone logged in?
  const session = await auth()
  if (!session?.user) {
    return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
  }

  const { id } = await params

  // 2. Authorization — scope the query to the current user
  const order = await db.order.findFirst({
    where: { id, userId: session.user.id },
  })

  // 3. Don't reveal whether the ID exists for someone else — return 404
  if (!order) {
    return NextResponse.json({ error: 'Not found' }, { status: 404 })
  }

  // 4. Return only the fields the client needs
  return NextResponse.json({
    id: order.id,
    total: order.total,
    status: order.status,
  })
}

Two lines do the heavy lifting: userId: session.user.id in the where clause, and the 404 when nothing matches. Returning 404 instead of 403 matters — a 403 ("forbidden") quietly confirms "yes, order 43 exists, you just can't have it," which itself leaks information. A 404 tells a snooping attacker nothing.

Option B — centralize it in a Data Access Layer. If you have twenty endpoints, you don't want to remember the ownership check in each one. The official Next.js authentication docs recommend a Data Access Layer (DAL): a server-only module that owns every database call and runs the authorization check in one place.

// lib/dal.ts — one place that can't be bypassed
import 'server-only'
import { auth } from '@/lib/auth'
import { db } from '@/lib/db'

export async function getOwnedOrder(orderId: string) {
  const session = await auth()
  if (!session?.user) return null

  // Ownership is enforced here, for every caller, forever
  return db.order.findFirst({
    where: { id: orderId, userId: session.user.id },
  })
}

Now your route handler is three boring lines, and there's exactly one function to audit:

const order = await getOwnedOrder(id)
if (!order) return NextResponse.json({ error: 'Not found' }, { status: 404 })

Note that scoping the query by userId is object-level authorization in your app code. It pairs perfectly with database-level Supabase Row Level Security — defense in depth, so one forgotten check doesn't sink you.

Before and after diagram of Next.js API route authorization: the insecure handler queries the order by ID alone and returns it, while the fixed handler scopes the query to the logged-in user's ID and returns 404 when it does not match

How to test the fix

You need two accounts. Create user A and user B. Log in as A, open DevTools → Network, and load one of A's records — note its id (say 42). Now, still logged in as A, request one of B's IDs directly:

# Logged in as user A, requesting user B's order
curl -i "https://your-app.vercel.app/api/orders/<user-B-order-id>" \
  -H "Cookie: session=<user-A-session-cookie>"

Before the fix: you get 200 and B's data comes back — that's the bug. After the fix: you get 404 and nothing leaks. Repeat the test against PATCH and DELETE handlers too — read access is bad, but write access is worse.

RiskWhat can happenFix
Login check only (no ownership)Any logged-in user reads others' records by changing the IDAdd userId: session.user.id to the where clause
IDOR on PATCH/DELETEAttackers edit or delete other users' dataScope every mutation query by owner, not just GET
403 instead of 404Confirms which IDs exist (info leak)Return 404 for records the user can't access
Auth only in middlewareMiddleware can't see the object ID, so it can't stop IDORCheck ownership next to the data, in the handler or DAL
Returning the whole DB rowInternal fields (emails, notes) leak to the clientReturn only the fields the UI needs

Copy-paste prompt: make your AI fix its own routes

Paste this into Cursor, Claude Code, or Copilot in your project:

Audit every Route Handler under app/api/**/route.ts that takes a dynamic [id] (or any user-supplied identifier). For EACH one: (1) confirm there is an authentication check; (2) confirm the database query is scoped to the current user — e.g. include userId: session.user.id in the where clause, not just where: { id }; (3) return 404 (not 403) when the record doesn't belong to the user, so existence isn't leaked; (4) apply the same ownership check to POST/PATCH/DELETE, not only GET; (5) return only the fields the client needs, never the raw row. List every handler you changed and exactly what was missing (this is Broken Object Level Authorization / IDOR).

Next.js API route authorization checklist

  • Every [id] route authenticates the session before doing anything
  • Every query that loads a record by ID is scoped to the current user (userId in the where clause)
  • PATCH, PUT, and DELETE handlers check ownership too — not just GET
  • Unauthorized/non-owned records return 404, not 403 or the data
  • Responses return a minimal DTO, never the raw database row
  • Ownership logic lives in a server-only Data Access Layer where practical
  • You tested with two accounts and confirmed user A can't fetch user B's ID

IDOR is one door in a building with many. The same "check it on the server, next to the data" rule governs your Server Actions, your database rules, and your auth flows — the full map is in our vibe coding security pillar guide, the closely related mutation version is in Next.js Server Actions Are Public Endpoints, and the scanners that catch what you miss are in the security tools roundup.

If you'd like a second pair of eyes on your actual code, I run hands-on security audits of vibe-coded Next.js apps — I'll walk every /api/[id] route, run the two-account IDOR test above, and hand you a prioritized fix list. Email me at [email protected] with your repo or stack and I'll tell you what an attacker would try first.

Key Takeaway

A login check answers "who are you?" — it never answers "are you allowed to touch this?" Every Next.js API route that loads a record by an ID from the URL needs an ownership check: scope the query to the logged-in user, return 404 when it doesn't match, and do it for reads and writes. Add userId: session.user.id to the where clause, prove it with a two-account curl test, and the IDOR your AI quietly shipped becomes a door an attacker can't open.