RAMYA LAKHANI

All writeups

In short

  • Middleware answers "is this a logged-in user". It cannot answer "does this row belong to them".
  • A route handler that reads an id from the URL and passes it straight to the database is an IDOR, even behind auth.
  • The fix is one clause: scope the query by the session user, so the database enforces ownership.

4 min read

The vulnerability class

Insecure Direct Object Reference is broken access control at the level of a single record. The server takes an identifier supplied by the caller, fetches the object it names, and returns it — without asking whether this caller is entitled to that object.

The bug is not the identifier being guessable. Sequential ids make it easier to find, but a UUID does not fix it; it only means the attacker needs a leaked id rather than a counter. The bug is the missing question.

Where it hides in modern stacks

In a Next.js App Router project, middleware.ts is the file that feels like the security layer. It runs before the request reaches the route, and once it is doing session checks it is easy to read the whole app as protected.

Middleware runs on the path. It knows there is a valid session and it knows the URL. It does not know that /api/orders/8231 names a row belonging to someone else, because it never loads the row.

That gap is where this bug lives, and it is why it survives code review: the reviewer sees authentication in the diff and stops.

export const config = { matcher: ['/api/:path*'] }   // middleware.ts — authenticated

Reproducing it

The handler below is behind that middleware. Every request to it has a valid session.

// app/api/orders/[id]/route.ts
export async function GET(req: Request, { params }: { params: { id: string } }) {
  const order = await db.order.findUnique({
    where: { id: params.id },
  })

  if (!order) return NextResponse.json({ error: 'not_found' }, { status: 404 })
  return NextResponse.json(order)
}

Sign in as user A, request one of your own orders, and note the id. Then change it:

GET /api/orders/8232 HTTP/1.1
Cookie: session=<user A's valid session>

If 8232 belongs to user B and the response is 200 with user B’s order, that is the finding. No token was forged and nothing was bypassed — the application was asked for someone else’s record and it handed it over.

The same handler is usually worse on writes. A PATCH built the same way lets user A modify user B’s row, and a DELETE lets them remove it. Test every method, not just the GET that is easy to spot.

The fix

Do not fetch and then check. Fetch in a way that cannot return the wrong row.

// app/api/orders/[id]/route.ts
export async function GET(req: Request, { params }: { params: { id: string } }) {
  const session = await getSession(req)
  if (!session) return NextResponse.json({ error: 'unauthorised' }, { status: 401 })

  const order = await db.order.findFirst({
    where: { id: params.id, userId: session.userId },   // ownership is part of the query
  })

  if (!order) return NextResponse.json({ error: 'not_found' }, { status: 404 })
  return NextResponse.json(order)
}

Two things make this hold rather than merely pass a test.

The ownership condition is in the where clause, so there is no state in which the handler holds a row it is not allowed to return. A later refactor that moves the response around cannot reintroduce the bug by dropping an if.

And the failure is 404, not 403. Answering 403 confirms the row exists, which turns the endpoint into an existence oracle — an attacker enumerating ids learns which ones are real even when they cannot read them.

Watch out. Hiding the control in the UI changes nothing. The client never enforced this; it only stopped displaying the link. Every check that matters happens on the server, on every request, including the ones your own interface never sends.

What still does not protect you

An ORM does not. findUnique is doing exactly what it was asked to do. The security property comes from what you put in the query, not from the library.

A UUID does not. It raises the cost of discovery and lowers it again the moment an id appears in a URL someone shares, a support ticket, a log line, or a webhook payload.

Rate limiting does not. It slows enumeration. Targeted access to one known id is a single request.

And fixing this route does not fix the next one. This is a per-endpoint property. The only durable version is a data-access layer that requires a caller identity, so the unsafe call is the one that is hard to write.