A few days ago I published a post about the three-layer auth model and the invoice incident that made me rebuild how I think about Next.js 16 auth. More people had hit the same thing than I expected.
One comment stopped me. Someone pointed out a real gap in how the forwarded headers work when the matcher misses a route. They were right, and I want to cover it properly here. But before that, I want to go through proxy.ts end to end, because in my experience the matcher is where most auth setups quietly break first, and it breaks in the worst way, no error, no warning, nothing in the logs.
Why the Name Changed
If you've been building with Next.js for a while, the rename felt arbitrary at first. middleware.ts to proxy.ts. Same location in your project, different filename, different export.
The Next.js team has been direct about why. The word "middleware" created real confusion. Developers coming from Express thought of it as a pipeline, stack things up, run them in order, app.use everything. That's not what this is and it led to people using it for things it was never meant to do: database calls, heavy business logic, session management.
What it actually does is sit at the network boundary in front of your app and intercept requests before they reach your routes. That's a proxy. The rename is the team saying: this has a specific job. Stop treating it like a general-purpose request pipeline.
The official docs are pretty clear:
Proxy is meant to be invoked separately of your render code. You should not attempt relying on shared modules or globals.
No database calls. No heavy logic. That belongs in the layers behind it, which is exactly what the previous post was about.
The Runtime Change That Actually Matters for Auth
middleware.ts defaulted to the Edge runtime. The Edge runtime had limited crypto support. Verifying JWTs with certain algorithms meant lighter libraries, specific workarounds, and sometimes things that just didn't work depending on which signing algorithm your tokens used.
proxy.ts runs on the Node.js runtime by default in Next.js 16. Full crypto support. jose works completely. Any standard JWT library works. No workarounds.
From the official version history:
v16.0.0: Middleware is deprecated and renamed to Proxy. Proxy defaults to the Node.js runtime
The Node.js runtime in proxy.ts is not configurable. The docs are explicit: the edge runtime is not supported in proxy, the runtime is nodejs, and it cannot be configured. Don't try to change it.
Edge runtime is still available through middleware.ts, which still exists in Next.js 16 for edge-specific cases like geographic redirects or A/B testing at the CDN level. But middleware.ts is deprecated. For auth, you want proxy.ts.
Migrating From middleware.ts
If you haven't done this yet, two options.
Full upgrade codemod for all Next.js 16 breaking changes:
npx @next/codemod@canary upgrade latest
Only the middleware migration if that's all you need:
npx @next/codemod@canary middleware-to-proxy .
After running either one, check these three things manually. Don't trust the codemod alone:
-
proxy.tsexists at your project root, same level as theappfolder - The exported function is named
proxy, notmiddleware -
middleware.tsis gone
That third one is more important than it sounds. If you manually bumped the package version without the codemod, your old middleware.ts sits there, compiles clean, passes TypeScript checks, and does nothing at runtime. Routes that should be intercepted aren't. Redirects don't fire. No error anywhere. The file is just silently bypassed.
I covered this in the 4 places Next.js 16 broke my app post. This is the one that hurt the most because everything looks fine until a real redirect fails to fire in staging.
Also check next.config.js if you had skipMiddlewareUrlNormalize — configuration flags containing the middleware name are renamed in Next.js 16, so this is now skipProxyUrlNormalize. The codemod handles it, but worth verifying manually.
Building the proxy.ts Auth Gate
Three decisions in this code that look obvious but aren't. I'll walk through each one after.
// proxy.ts
import { NextResponse } from "next/server"
import type { NextRequest } from "next/server"
import { jwtVerify } from "jose"
// npm install jose
const JWT_SECRET = new TextEncoder().encode(process.env.JWT_SECRET!)
const PUBLIC_ROUTES = [
"/login",
"/register",
"/forgot-password",
"/reset-password",
"/api/auth/login",
"/api/auth/refresh",
"/api/auth/logout",
"/",
"/about",
"/pricing",
"/blog",
]
const ROLE_ROUTES: Record<string, string[]> = {
"/admin": ["admin"],
"/dashboard": ["admin", "user", "moderator"],
"/moderator": ["admin", "moderator"],
"/api/admin": ["admin"],
"/api/user": ["admin", "user", "moderator"],
}
export async function proxy(request: NextRequest) {
const { pathname } = request.nextUrl
if (PUBLIC_ROUTES.some((route) => pathname.startsWith(route))) {
return NextResponse.next()
}
const tokenCookie = request.cookies.get("auth_tokens")?.value
if (!tokenCookie) {
const loginUrl = new URL("/login", request.url)
loginUrl.searchParams.set("redirect", pathname)
return NextResponse.redirect(loginUrl)
}
try {
const tokens = JSON.parse(tokenCookie)
const { payload } = await jwtVerify(tokens.accessToken, JWT_SECRET)
const role = payload.role as string
for (const [route, allowedRoles] of Object.entries(ROLE_ROUTES)) {
if (
(pathname === route || pathname.startsWith(route + "/")) &&
!allowedRoles.includes(role)
) {
return NextResponse.redirect(new URL("/unauthorized", request.url))
}
}
// Headers go on the request, not the response
// Server Components read incoming request headers via headers()
// Setting them on the response sends them to the browser instead
const requestHeaders = new Headers(request.headers)
requestHeaders.set("x-user-id", payload.sub as string)
requestHeaders.set("x-user-role", role)
requestHeaders.set("x-user-email", (payload.email as string) ?? "")
return NextResponse.next({
request: { headers: requestHeaders },
})
} catch {
// Expired token, malformed JWT, bad JSON — all redirect to login
// Same response for all three, no information leak about which failed
const loginUrl = new URL("/login", request.url)
loginUrl.searchParams.set("redirect", pathname)
return NextResponse.redirect(loginUrl)
}
}
export const config = {
matcher: [
"/((?!_next/static|_next/image|favicon.ico|sitemap.xml|robots.txt|.*\\.png$|.*\\.jpg$|.*\\.webp$|.*\\.svg$|.*\\.ico$).*)",
],
}
Decision 1: The Matcher
This is where most auth setups break. And the way it breaks is the worst possible kind.
Without a matcher, the proxy runs on every request. Every CSS file. Every JS bundle. Every image. That's a JWT verification attempt on each one. When an unauthenticated user hits a CSS request, they get redirected to login, which has no token, which redirects to login again. Infinite redirect loop on static assets. Your app loads for authenticated users but something always feels slow and wrong, and the logs don't explain it.
The negative lookahead in the matcher above excludes static files and keeps the proxy on actual routes:
-
_next/static: compiled JS and CSS bundles -
_next/image: image optimization endpoint -
favicon.ico,sitemap.xml,robots.txt: metadata files -
.*\\.png$and the other image extensions : public folder assets
One behavior worth knowing: even if you exclude _next/data in your matcher, the proxy still runs for _next/data routes. This is intentional by design. If you protect a page, the proxy deliberately still covers the corresponding data route so you can't accidentally leave it exposed.
Matcher values must be constants. Statically analyzed at build time. Dynamic values, variables, anything computed gets silently ignored. Another way auth gaps get introduced with zero error output.
Decision 2: The Header Direction
I got this backwards the first time I wrote it.
// This is correct. Headers reach your Server Components
return NextResponse.next({
request: { headers: requestHeaders },
})
// This is wrong. Headers go to the browser instead
// No error produced
return NextResponse.next({
headers: requestHeaders,
})
headers() in a Server Component returns incoming request headers. If you set the x-user-id header on the response instead of on the forwarded request, every headers().get("x-user-id") call in your pages returns null. Every authenticated user gets redirected to login. Nothing in the logs to explain it. Took me way longer to debug than it should have.
Decision 3: The try/catch
The catch block handles three failure modes with one redirect: the cookie is valid JSON but the tokens object is malformed, the JWT is structurally broken, or the JWT is expired. All three end at login with no indication of which failed.
Different error responses for different failure modes tell an attacker something about the state of your system. One generic redirect tells them nothing.
The Header Trust Boundary
The proxy sets x-user-id on the request headers before forwarding. Server Components read it with headers().get("x-user-id"). Works fine when the proxy runs.
When does the proxy not run? When the matcher has a gap.
Add a new route, forget to check it falls inside the matcher pattern, the proxy never runs for that route. Nobody set x-user-id. Now a client sends their own x-user-id: someone_elses_id header on that unmatched route. The Server Component reads it. From inside the Server Component, a proxy-set header and a client-sent header look identical — there's no way to tell the difference.
What breaks: if you use that userId to query data, the data layer's AND user_id = $2 still scopes the query correctly. Actual records don't leak. But getUserPermissions(userId) now runs against the wrong user entirely. The attacker gets a different user's permissions back. No records exposed, but a real authorization failure on a specific route.
The fix is verifying the JWT directly from the cookie in the Server Component instead of trusting the forwarded header.
// lib/auth-server.ts
import { cookies } from "next/headers"
import { jwtVerify } from "jose"
const JWT_SECRET = new TextEncoder().encode(process.env.JWT_SECRET!)
type AuthUser = {
userId: string
role: string
email: string
}
export async function getVerifiedUser(): Promise<AuthUser | null> {
const cookieStore = await cookies()
const tokenCookie = cookieStore.get("auth_tokens")?.value
if (!tokenCookie) return null
try {
const tokens = JSON.parse(tokenCookie)
const { payload } = await jwtVerify(tokens.accessToken, JWT_SECRET)
return {
userId: payload.sub as string,
role: payload.role as string,
email: (payload.email as string) ?? "",
}
} catch {
return null
}
}
Using it in a Server Component:
// app/dashboard/billing/page.tsx
import { redirect } from "next/navigation"
import { getVerifiedUser } from "@/lib/auth-server"
import { getUserPermissions, getUserInvoices } from "@/lib/data"
export default async function BillingPage() {
const user = await getVerifiedUser()
if (!user) {
redirect("/login")
}
const permissions = await getUserPermissions(user.userId)
if (!permissions.includes("billing:read")) {
redirect("/unauthorized")
}
const invoices = await getUserInvoices(user.userId)
return <BillingView invoices={invoices} />
}
Yes, this verifies the JWT twice on requests that go through the proxy. The proxy verifies at the network boundary, the Server Component verifies again at render time. That feels redundant. It isn't.
The proxy verification is the fast gate. The Server Component verification removes the trust dependency on the proxy having run at all. On a route where the proxy ran normally, the second verification takes a few milliseconds. On a route where the matcher had a gap, it's what closes the hole. Trusting a header any client can send on any unmatched route is not worth the few milliseconds saved.
Server Functions and the Proxy
Something in the official docs tucked into the execution order section that gets missed:
Server Functions are not separate routes in this chain. They are handled as POST requests to the route where they are used, so a Proxy matcher that excludes a path will also skip Server Function calls on that path.
If your matcher excludes a path, Server Actions on that path run without proxy coverage too. The docs explicitly say to verify authentication and authorization inside each Server Function, not rely on proxy coverage alone.
Same principle as the three-layer model. The proxy is the fast gate, not the complete answer.
Where proxy.ts Sits in the Request Flow
From the official docs, the actual execution order:
-
headersfromnext.config.js -
redirectsfromnext.config.js - proxy.ts
-
beforeFilesrewrites fromnext.config.js - Filesystem routes (
public/,_next/static/,pages/,app/) -
afterFilesrewrites fromnext.config.js - Dynamic routes
-
fallbackrewrites fromnext.config.js
Third. After next.config.js headers and redirects, before anything in the filesystem renders. That's why it's cheap, it intercepts before any React component work starts.
Three Things to Check Before You Ship
JWT_SECRET in every environment. Always in .env.local. Easy to forget in staging or production. The jwtVerify call throws an opaque error when it's missing and authenticated users get sent to login with nothing in the logs that explains why.
Actually verify the migration ran. Check that proxy.ts exists at project root, the export is named proxy, and middleware.ts is deleted. Running the codemod and assuming it worked is not the same as checking. Manual upgrade with no codemod means the old file sits there silently doing nothing with zero warning.
Check the matcher every time you add a protected route. New route, check it falls inside the matcher pattern, check it appears in ROLE_ROUTES. Most auth gaps I've seen get introduced weeks after the initial proxy setup when someone adds a route and nobody goes back to verify coverage.
The proxy is the fast gate. It's essential and does its job well at the network boundary.
It can't answer ownership questions. It can't stop one user from seeing another user's data. And the forwarded header pattern has a real trust boundary issue on any route where the matcher has a gap.
Next post covers the Server Component authorization layer, roles versus permissions, why permissions need a database call that roles don't, and how the independent check at render time catches what the proxy structurally cannot.
Full implementation with all three layers: shubhra.dev/tutorials/nextjs-16-authentication-3-layer-security. The proxy setup in this post maps to Step 2 there. The getVerifiedUser utility above updates the Server Component pattern in Step 3 to close the header trust boundary gap.
Note: I use AI for editing and structure, but the technical substance is from my own work.













