Skip to content
Rotwise
Findings5 min read

Next.js API routes with no authentication: find and fix them

AI assistants write Next.js route handlers that skip the auth check. How to find every unprotected route, the session check that belongs on line one, and the two traps that keep apps open.

Ask an assistant for "an API route that returns the user's orders" and you will get a route handler that returns orders. All of them. To anyone. The handler is correct in every way except the one that matters, and because Next.js makes route handlers so easy to write, a generated app often has a dozen of them before anyone opens one and asks who is allowed to call it.

This is how to find every unprotected route in a Next.js app, the check that belongs on line one, and the two traps that make a protected-looking app still open.

What the pattern looks like

// app/api/orders/route.ts
export async function GET() {
  const orders = await prisma.order.findMany()
  return Response.json(orders)
}

In the Pages Router the same thing is a default export in pages/api/orders.ts. Either way the handler reads the database, returns the result, and never reads a session, a cookie or an Authorization header. The prompt described the data. It did not describe the caller.

Find every one of them

Route handlers are easy to enumerate, which makes this one of the more satisfying audits. List every file that exports a request handler, then remove the ones that mention a session or token check anywhere in the file. What is left is your exposure.

grep -rlE "export (async )?function (GET|POST|PUT|PATCH|DELETE)" app/api pages/api \
  | xargs grep -L -E "auth\(|getServerSession|getToken|currentUser|supabase.auth|Authorization"

Read each remaining file and sort it into one of two lists: genuinely public, such as a health check or a webhook receiver with its own signature verification, or private and missing the check. The second list is this week's work. Rotwise runs essentially this audit as a rule and flags a handler file with no visible session or token read as a P0 finding.

The check that belongs on line one

import { auth } from "@/auth"

export async function GET() {
  const session = await auth()
  if (!session?.user) {
    return new Response("Unauthorized", { status: 401 })
  }
  const orders = await prisma.order.findMany({
    where: { userId: session.user.id },
  })
  return Response.json(orders)
}

Two things happen here and both matter. The first is authentication: no session, no work, a 401 before any query runs. The second is ownership: the query is scoped to the signed-in user. A handler that checks the session and then returns findMany() with no filter is still leaking every other customer's orders to any signed-in account.

The exact import depends on your auth library. NextAuth exposes auth(), Clerk exposes auth() from its server package, Supabase exposes getUser() on a server client. The shape is the same everywhere: read the session first, refuse early, scope every query.

Trap one: the middleware matcher that skips the API

export const config = {
  matcher: ["/((?!api|_next/static|_next/image|favicon.ico).*)"],
}

This matcher is copied into thousands of apps, often by the assistant itself, and it explicitly excludes /api. The pages are protected. The API that the pages call is not, and anyone with the URL can call it directly. Either include the API routes in the matcher or, better, keep the session check inside each handler so a route is protected even when someone edits the middleware later.

Trap two: the server action that trusts its arguments

Server actions are route handlers with a nicer syntax, and they get the same treatment from assistants. An action that receives an orderId and updates that order must check both that there is a session and that the order belongs to the session's user. Reading the id from the arguments and trusting it is the same bug with a different file extension.

Keep it fixed

  • Write one request test per private route that asserts a 401 without a session. It is the cheapest test in the codebase and it catches the regression the day someone adds a new handler from a prompt.
  • Put the audit command above in CI and fail the build when the second list is not empty.
  • Add "only the signed-in owner can call this" to every prompt that creates a handler. The assistant will write the check if it is asked to.

This is one of the five security holes assistants leave behind, and in a Next.js app it is usually the one with the most instances. The audit takes an afternoon. The alternative is finding out from a customer.

← All posts