← Blog
Guides10 min read

Next.js Authentication Middleware Is Now Proxy

What the Next.js 16 rename changes for route protection, the Auth.js code that runs in production, and the mistake we shipped.

In Next.js 16 the file that protects your routes is called proxy.ts. The middleware convention is deprecated and renamed, the exported function is renamed with it, and the official upgrade codemod does both along with the config flags that carried the old name.

The rename is the easy part. Here is the whole thing with Auth.js v5, and it is the same shape as before:

// proxy.ts, at the root of src/
import { auth } from "@/auth"
import { NextResponse } from "next/server"

export default auth((req) => {
  const isLoggedIn = !!req.auth
  if (req.nextUrl.pathname.startsWith("/dashboard") && !isLoggedIn) {
    return NextResponse.redirect(new URL("/login", req.nextUrl))
  }
  return NextResponse.next()
})

The part worth your attention is not the name. It is what you should stop putting in that file, why the framework changed the word, and one mistake that is easy to make and hard to notice, because we shipped it ourselves and only found it through a typo.

What actually changed in Next.js 16

Three things, and only one of them is cosmetic.

The file and the function are renamed. middleware.ts becomes proxy.ts, the named export middleware becomes proxy, and flags like skipMiddlewareUrlNormalize become skipProxyUrlNormalize. The deprecated convention still works, so nothing breaks the day you upgrade.

The edge runtime is gone from this file. Proxy runs on the Node.js runtime, and that is not configurable: setting the runtime option throws. If you truly need the edge, the old middleware file remains available for now. For authentication this is mostly a relief, because the constraint that pushed people toward edge-compatible token libraries and away from their normal database client no longer applies here.

The word changed because the role changed. The Next.js team renamed it to stop the comparison with Express middleware, which invited people to treat it as a place to put application logic. A proxy is a network boundary in front of your app. That framing is the actual advice, and the rest of this guide is what follows from it.

Three checks on one request: the proxy redirects, the layout gates with auth(), and the server action verifies again, so a request that skips the proxy is still stopped

Protecting routes with Auth.js v5

Auth.js exports an auth function that doubles as a wrapper. Wrapping the proxy gives you the session on req.auth without any manual cookie parsing, and the whole file stays declarative. This is the version running on the kit this site is built with:

const PROTECTED_ROUTES = ["/dashboard", "/api/checkout", "/api/billing"]
const ADMIN_ROUTES = ["/admin", "/api/admin"]
const AUTH_ROUTES = ["/login", "/signup"]

export default auth((req) => {
  const { pathname } = req.nextUrl
  const isLoggedIn = !!req.auth
  const isAdmin = req.auth?.user?.role === "ADMIN"

  // Signed in users have no business on the sign-in page.
  if (AUTH_ROUTES.some((r) => pathname.startsWith(r)) && isLoggedIn) {
    return NextResponse.redirect(new URL("/dashboard", req.nextUrl))
  }

  if (ADMIN_ROUTES.some((r) => pathname.startsWith(r))) {
    if (!isLoggedIn) return NextResponse.redirect(new URL("/login", req.nextUrl))
    if (!isAdmin) return NextResponse.redirect(new URL("/dashboard", req.nextUrl))
    return NextResponse.next()
  }

  if (PROTECTED_ROUTES.some((r) => pathname.startsWith(r)) && !isLoggedIn) {
    return NextResponse.redirect(new URL("/login", req.nextUrl))
  }

  return NextResponse.next()
})

The role lives on the token, so the admin check costs nothing extra. Getting it there is one line in the JWT callback, and it is worth doing deliberately: a role read from the token is a claim you signed, while a role read from the client is a claim the client made.

Use a denylist, not an allowlist

This is the section we can write because we got it wrong.

That list of protected prefixes started life as its opposite: an allowlist of public paths, with everything else treated as private. It reads as the safer default. It is not, and the failure is quiet.

Every path the list did not know about looked private. A visitor who typed /doc instead of /docs was redirected to /login instead of seeing a 404. Worse, our 404 page was effectively unreachable for anyone not signed in, on a site whose whole job is to be read by strangers. Nothing errored. Nothing appeared in the logs. It looked like working authentication.

We inverted it to the denylist above: name what is private, let everything else fall through to the router. And the reason inverting it opens nothing is the next section.

The redirect loop, and the routes you must not touch

The classic failure of this file is a loop. You protect a set of paths, an anonymous visitor is redirected to /login, and /login matches the protected set too, so it redirects to /login, forever. The browser gives up with a "too many redirects" error and the page never renders.

An allowlist makes this easy to cause, because you have to remember to allow the sign-in page. A denylist makes it hard, because /login is simply not in the list of private prefixes. The only rule left is the mirror case, which is a nicety rather than a bug: send signed-in users away from the sign-in page, which is the AUTH_ROUTES branch above.

There is a second family of paths that must pass untouched, and this is where an allowlist gets genuinely dangerous:

  • /api/auth/*, the Auth.js callback routes. Redirect them and you break the very flow that creates the session, in a way that looks like "OAuth is broken" rather than "my proxy is wrong".
  • Inbound webhooks, for example the Stripe endpoint. They carry no cookie and never will. Bounce them to /login and the payment provider receives a 307 instead of a 200, retries for a while, and gives up. Your checkout keeps working, your subscriptions quietly stop being recorded, and nothing in your app looks broken until someone notices a paying customer with no access.

With a denylist both cases are free: neither path is listed, so neither is touched, and there is nothing to remember. With an allowlist both are a line you must write correctly the first time, and the webhook one fails in silence.

The proxy is not a security boundary

Loosening that file was safe because it was never the only gate. Each private area checks its own session on the server:

// app/(admin)/layout.tsx
const session = await auth()
if (!session) redirect("/login")
if (session.user.role !== "ADMIN") redirect("/dashboard")

The dashboard layout does the same with its own rule, and every private API route answers 401 on its own. The proxy exists to send people somewhere sensible before a page renders. It is user experience with a security flavour, not the wall.

If that sounds like belt and braces, there is a documented reason to insist on it. In March 2025, CVE-2025-29927 was published against Next.js with a CVSS score of 9.1, critical. A crafted x-middleware-subrequest header, an internal mechanism meant to prevent infinite loops, made the framework skip middleware entirely. Every app whose authorization lived only in that file was open to anyone who knew the header name. It was patched in 12.3.5, 13.5.9, 14.2.25 and 15.2.3, and Next.js 16 is unaffected, but the lesson outlived the bug: a check that can be skipped by a header was never a boundary.

There is a quieter version of the same problem that no patch fixes. Server functions are not separate routes: they are POST requests to the route where they are used. A matcher change, or a refactor that moves an action into a different route, can remove proxy coverage without a single error. The Next.js documentation says it plainly, and so does the Auth.js guide to protecting resources: do not rely on the proxy exclusively, and verify close to the data.

What belongs where

ProxyLayout or pageServer action or route handler
Runs onevery matched request, before renderingthe request that renders the areathe request that performs the operation
Right jobredirect, rewrite, block a whole area earlygate a section and shape what it rendersauthorize the operation and its data
Session accessreq.auth from the wrapperawait auth()await auth()
Can be bypassedhistorically yes, and silently for server functionsno: it is the render itselfno: it is the operation itself
Wrong jobreading your database, business rules, per-record permissionsnothing, this is where it belongsnothing, this is where it belongs

The short rule: if skipping the check would let someone read or change data, the check does not belong in the proxy alone.

What it costs to check on every request

The proxy runs on nearly every request that matches, and whatever you put in it runs there too. That makes the session strategy a performance decision as much as a security one.

Database sessions are a row you can delete, which makes revocation trivial and costs one query per matched request. JWT sessions are a signed cookie: nothing is queried, and nothing can be deleted either. Email and password sign-in forces the JWT strategy in Auth.js, so most real applications end up with a session they cannot revoke by deleting anything.

The fix is a version number on the user, bumped on password reset, and a check in the token callback. The interesting part is the throttle:

// Re-verify at most once a minute, because the proxy runs auth() on
// nearly every request. Fails OPEN on a database error: for this threat
// model, availability beats strictness.
if (token.sub && now - (token.svAt ?? 0) > 60_000) {
  const dbUser = await prisma.user.findUnique({
    where: { id: token.sub },
    select: { sessionVersion: true },
  })
  if (!dbUser || dbUser.sessionVersion !== token.sv) return null
  token.svAt = now
}

Two decisions are visible there, and both are the kind you should make on purpose rather than inherit. Revocation is eventual, within about sixty seconds, because a check on every request would put a database round trip in front of every page. And it fails open, so a database blip does not sign your users out. If your threat model says a revoked session must die instantly, that is the line to change, and you pay for it in latency.

The matcher, and what it quietly skips

Without a matcher the proxy runs on everything, including static files and image optimization. That is a real cost on a file that calls auth(). The exclusion list is the boring part that matters:

export const config = {
  matcher: [
    "/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)",
  ],
}

Two things are worth knowing about it. Matcher values must be static, because they are analysed at build time: a pattern built from a variable is ignored, silently. And _next/data routes still invoke the proxy even when a negative pattern appears to exclude them, which is deliberate, so that protecting a page does not accidentally leave its data route open.

Frequently asked questions

Do I have to rename middleware.ts to proxy.ts?

Not immediately. The middleware convention is deprecated in Next.js 16, not removed, so an existing file keeps working. The official codemod does the rename, the exported function and the renamed config flags in one command, so there is little reason to postpone it beyond your next upgrade window.

Can I keep the edge runtime in proxy.ts?

No. Proxy runs on the Node.js runtime and that is not configurable: setting the runtime option throws. If you specifically need the edge runtime, the old middleware file still works for now. For authentication this is usually good news, because the Node runtime removes the library constraints that made session checks awkward at the edge.

Is it safe to check authentication only in the proxy?

No, and this is the one rule worth taking literally. In March 2025 a critical advisory, CVE-2025-29927 with a CVSS score of 9.1, showed that a crafted header could make Next.js skip middleware entirely on unpatched versions. Any app whose only authorization check lived there was open. Treat route-level protection as user experience and put the real check next to the data.

Does the proxy protect server actions?

Not reliably. Server functions are not separate routes: they are POST requests to the route where they are used, so a matcher change or a refactor that moves one to another route can silently drop proxy coverage. Every server action that reads user data or performs a privileged operation needs its own check.

Does running auth() in the proxy hit the database on every request?

It depends on your session strategy. With database sessions, yes, one query per matched request. With JWT sessions the token is read from a signed cookie and nothing is queried, which is fast but means a session cannot be revoked by deleting a row. If you add a revocation check, throttle it: we re-verify at most once a minute rather than on every request.

Read the whole file instead

Every snippet here is one file in a free, MIT licensed starter kit, running on Next.js 16.2 and Auth.js v5, with the layered checks already in place. Clone it and read src/proxy.ts next to the layouts it does not replace, or open the authentication setup guide for the providers behind it.

If you are still deciding whether to run authentication yourself at all, the longer argument is in self-hosted authentication in Next.js, which covers what you own, what it costs to maintain, and when renting it is the better call.