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.
Top comments (15)
Excellent breakdown.
What stood out to me is that the matcher issue is really a trust-boundary issue in disguise. The moment authorization logic depends on propagated identity data, every path that can bypass the propagation layer becomes part of the security model.
One thing I’m curious about: how do you approach the transition from role-based authorization to fine-grained permission models as applications grow? I’ve found that matcher gaps are usually discovered quickly, but authorization complexity tends to emerge gradually as roles multiply and business rules become more granular.
Great example of why security architecture is often more about assumptions than code.
Thanks for the close read. You're right on both.
The matcher gap is a trust boundary issue, full stop. That's why I moved away from trusting forwarded headers and verify the JWT from the cookie directly in the Server Component. Verifying twice feels wasteful, but it's the only way to close the hole. Proxy is fast, not authoritative.
On roles to permissions: keep roles in the JWT for the proxy, resolve permissions from the DB in the Server Component. Permissions change too often to bake into a token. The proxy checks roles, page checks permissions, data layer scopes every query by userId. That's the only layer that can't be misconfigured into not running.
The proxy is a fast gate. The security boundary is the data layer. Everything else is defense in depth.
That makes a lot of sense.
I especially like the distinction between the proxy as a fast gate and the data layer as the real security boundary. In many systems, people try to make the first auth layer carry too much responsibility. It works until one route, one rewrite, or one server action escapes that layer. Keeping roles in the token for quick routing decisions, resolving permissions closer to the page, and enforcing ownership at the data layer feels like the right separation of responsibilities.In the end, every layer can reduce risk, but the data layer is where authorization mistakes either become harmless or become incidents.
This is a goldmine of production insights. Transitioning from middleware.ts to proxy.ts in Next.js 16 isn't just a rename; it's a fundamental architectural shift.
You hit the nail on the head regarding the header trust boundary risk. If a developer forgets to update the matcher, a malicious user spoofing the x-user-id header on an unmatched route bypasses the gate completely. Your strategy of implementing double-verification (validating the token inside Server Components with getVerifiedUser) is an exceptional fix. It cleanly detaches data safety from edge-case matcher gaps.
Also, pointing out that orphaned middleware.ts files compile cleanly but fail silently in production is the exact type of hard-earned debugging advice that makes Dev.to posts invaluable. Fantastic work on prioritizing a bulletproof zero-trust model!
Appreciate it. The orphaned middleware.ts thing was especially painful. CI passed, build passed, everything green. Then a redirect just didn't fire in staging. Took way too long to trace back to a file that was literally sitting there doing nothing.
The double-verification felt wrong at first. Like I was being inefficient. But once I saw someone could just send their own x-user-id on a missed route, it became non-negotiable. I'd rather verify twice than debug once.
I'd rather verify twice than debug once" is going on my wall. Exactly this. Those silent bypasses in staging are where developer sanity goes to die. Glad the breakdown resonated, cheers!
Nice work debugging Next.js 16, as always! 😄
Thanks. Learned a lot on this one.
ran into this - had a matcher pattern that missed a route, middleware just never fired. didn't catch it until staging. the part that gets me is how long it can sit there undetected.
That's exactly the failure mode I covered. I got burned by it with /reports. Built fine, tests green, nothing in the logs. QA found it two weeks later by accident. Now it's just a checkbox on every PR. New route, verify the pattern, verify ROLE_ROUTES. Took two times before I stopped assuming I'd remember.
The header trust boundary point is the one most people miss until it bites them.
This gets worse in multi-tenant setups, where x-user-id alone isn't enough
you also need x-tenant-id propagated and verified independently, because a matcher gap there doesn't just leak permissions, it can let a request resolve against the wrong tenant's data entirely. Re-verifying both at the Server Component level becomes non-negotiable once you're not on a single-tenant assumption
Good point. Multi-tenant makes the header trust boundary go from "annoying bug" to "actual incident" real fast. x-tenant-id from an untrusted header hitting the wrong tenant's data is exactly the same class of problem, just higher stakes. Re-verifying both from the cookie at the Server Component level is the same fix, just with more fields. The pattern holds: proxy forwards for convenience, Server Component verifies for authority.
The header trust boundary example is something I hadn't really considered before, especially how a matcher gap can turn forwarded headers into a security risk. The "fast gate, not the source of truth" mindset for proxy.ts makes a lot of sense.
The matcher gap thing is exactly the kind of bug that looks impossible until you see it once. After that you stop trusting any header you didn't verify yourself.
The "fast gate" framing took me too long to accept. I kept wanting the proxy to do more than it can. It redirects unauthenticated users well, but that's routing, not authorization. The real check happens where the data lives.
Hey, this article appears to have been generated with the assistance of ChatGPT or possibly some other AI tool.
We allow our community members to use AI assistance when writing articles as long as they abide by our guidelines. Please review the guidelines and edit your post to add a disclaimer.
Failure to follow these guidelines could result in DEV admin lowering the score of your post, making it less visible to the rest of the community. Or, if upon review we find this post to be particularly harmful, we may decide to unpublish it completely.
We hope you understand and take care to follow our guidelines going forward!