A few months ago I was doing a final pre-release review on a client project before handing it over.
Protected routes were protected. Unauthenticated users got redirected. Tokens expired correctly. I had tested it three different ways and everything held up. I was confident enough to stop thinking about it.
Then I found something.
A user with a valid session, the right role, and a correct JWT could still request another user's invoice if they knew the ID. No error in the logs. No failed requests. The auth was technically correct at every layer I had actually built.
The problem was I had only built one layer and called it done.
That afternoon changed how I think about Next.js 16 authentication entirely. And I have been building it differently ever since.
One Thing to Check Before Writing a Line of Auth Code
If you are coming from Next.js 15, middleware.ts is deprecated in Next.js 16 and replaced by proxy.ts. Same location in your project, different filename, different exported function name. middleware.ts still works for Edge runtime use cases but will be removed in a future version.
// Before: middleware.ts
export function middleware(request: NextRequest) { ... }
// After: proxy.ts
export function proxy(request: NextRequest) { ... }
The auth-relevant change is the runtime. middleware.ts defaulted to Edge runtime, which had limited crypto support. Verifying JWTs in Edge required lighter libraries and sometimes workarounds depending on which algorithms your tokens used.
proxy.ts runs on Node.js runtime in Next.js 16. jose works completely. Any standard JWT library works. It removes the Node-crypto limitations that existed in the Edge runtime, which is a massive improvement for auth specifically.
# Handles the rename and all other Next.js 16 breaking changes
npx @next/codemod@canary upgrade latest
# Only the middleware migration if that is all you need
npx @next/codemod@canary middleware-to-proxy .
After running it: verify proxy.ts exists in your project root and the exported function is named proxy. Remove middleware.ts to avoid ambiguity. While it is still supported for Edge runtime, it is deprecated in Next.js 16 and the framework expects proxy.ts going forward.
Keeping both files can lead to confusion about which one is actually handling requests.
One important constraint: proxy.ts is designed for redirects, rewrites, header modifications, and direct responses. It is not intended for slow data fetching or full session management, and should not be used as your primary authorization layer. This is by design to keep the proxy focused on fast network boundary concerns.
Why proxy.ts Is Not Your Security Layer (Even Though It Looks Like One)
This is the thing almost every Next.js auth tutorial gets wrong. Not in an obvious way. In the way that passes all your tests and breaks in production.
proxy.ts runs at the network boundary before anything renders. It reads your session cookie, verifies the JWT, checks the role, and redirects or continues. Fast, essential, genuinely useful. Every auth setup needs it.
But it only sees URL patterns.
When it decided my user could access /dashboard/invoices, its job was done. It had no idea which specific invoice ID was in the URL, who owned that invoice, or whether the person requesting it had any right to see it. From the proxy's point of view, the URL matched, the role matched, the token was valid. Green light.
That gap is the invoice incident. The proxy was correct. It was just correct about the wrong thing.
// proxy.ts: role-based route protection, correct and valuable
const ROLE_ROUTES: Record<string, string[]> = {
"/admin": ["admin"],
"/dashboard": ["admin", "user", "moderator"],
}
// The proxy sees /dashboard/invoices/abc123 and approves it
// It has no way to know if abc123 belongs to this user
// Not a proxy bug. A category mismatch.
The proxy cannot answer ownership questions. Nobody can answer ownership questions from a URL pattern alone. You need the database for that. And the database is not where the proxy runs.
Treating proxy.ts as the complete security layer is not a configuration mistake. It is a mental model mistake. And it is the one almost every auth tutorial makes by stopping there.
The Check That Runs Even When Your Proxy Has a Gap
Server Components render when the page renders. That sounds obvious, but the implication is worth sitting with: Server Components run after the proxy and act as a second layer of enforcement. Even if a route slips past a misconfigured proxy matcher gap, this check still runs.
This is also where you catch the thing the proxy structurally cannot catch: permission-level decisions that depend on your database.
// app/dashboard/billing/page.tsx
import { headers } from "next/headers"
import { redirect } from "next/navigation"
import { getUserPermissions, getUserInvoices } from "@/lib/data"
export default async function BillingPage() {
const headerStore = await headers()
const userId = headerStore.get("x-user-id")
// This check runs even if the proxy had a gap on this route
if (!userId) {
redirect("/login")
return
}
// Role came from the JWT, fast, no DB call needed
// Permissions need a DB call because they change more often than roles
const permissions = await getUserPermissions(userId)
if (!permissions.includes("billing:read")) {
redirect("/unauthorized")
}
const invoices = await getUserInvoices(userId)
return <BillingView invoices={invoices} />
}
The split between roles and permissions is intentional. Roles are stable enough to embed in a JWT. The proxy reads them without touching the database. Permissions change when you update access settings and you do not want to wait for a JWT to expire before that change takes effect. Roles in the proxy, permissions in the Server Component. That boundary matters.
The x-user-id header gets there because the proxy sets it before forwarding the request:
// Inside proxy.ts after JWT verification
const requestHeaders = new Headers(request.headers)
requestHeaders.set("x-user-id", payload.sub as string)
requestHeaders.set("x-user-role", role)
return NextResponse.next({
request: { headers: requestHeaders },
})
// 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
// Your pages never see them if you get this backwards
I got this backwards the first time I wrote it. No error anywhere. The headers() call in the Server Component just returned null for both fields and the page redirected everyone to /login including fully authenticated admins. Spent an embarrassing amount of time on that one.
The Backstop That Would Have Caught the Invoice Bug No Matter What
Even if the proxy had a gap and the Server Component had a gap on the same day, this layer still holds.
Every data function that returns user-specific data takes a userId and uses it inside the query. Not as a convenience. As the actual access control.
// lib/data.ts
// This is what my data layer looked like before the incident
export async function getInvoice(invoiceId: string) {
return db.query(
"SELECT * FROM invoices WHERE id = $1",
[invoiceId]
)
}
// Anyone with any valid session could call this with any ID
// No error. No log entry. Just data returned to whoever asked.
// This is what it looks like now
export async function getInvoice(invoiceId: string, userId: string) {
const invoice = await db.query(
"SELECT * FROM invoices WHERE id = $1 AND user_id = $2",
[invoiceId, userId]
)
if (!invoice) {
throw new Error("Not found")
}
return invoice
}
The same error for "not found" and "not authorized" is intentional. Different errors let an attacker enumerate which IDs exist in your system. One generic error closes that information leak.
The ownership check lives inside the SQL. It cannot be misconfigured away. It cannot be missing from a route someone added last week. It cannot have a matcher gap. The authorization is in the query itself and the query runs every single time.
This is the layer that was missing from my app. The other two were correct. This one did not exist.
What All Three Layers Look Like on One Request
When an authenticated user hits /dashboard/invoices/abc123:
Request arrives
proxy.ts
Reads auth_tokens cookie (written by your auth API on login)
Verifies JWT (full Node.js runtime in Next.js 16, jose works completely)
Checks role against ROLE_ROUTES
Wrong role? Redirect to /unauthorized
Correct role? Set x-user-id, x-user-role on request headers, continue
app/dashboard/invoices/[id]/page.tsx
Reads x-user-id from forwarded headers, no re-verification needed
Checks billing:read permission against DB
Missing permission? redirect('/unauthorized')
Has permission? Call getInvoice(id, userId)
lib/data.ts: getInvoice(invoiceId, userId)
SELECT * FROM invoices WHERE id = $1 AND user_id = $2
No row? throw new Error('Not found')
Row exists? User owns it. Return data.
Component renders.
Three independent checks. A bug at any one of them does not open a door because the other two are still running.
One setup requirement worth knowing before you build this: the proxy reads the session from a cookie. If your auth client stores tokens in localStorage by default, the proxy cannot see them at all and will redirect every authenticated request straight to login. Make sure your auth setup writes tokens to a cookie. The proxy template in the full implementation reads a cookie named auth_tokens set on login.
The invoice incident had no bug in the proxy. No bug in the Server Component. One missing AND user_id = $2 in a data function. That was the whole thing.
The Mental Model in One Sentence
The proxy decides who can reach a route. The Server Component decides who can render a page. The data layer decides who can see a record.
All three are required. Any one of them alone will eventually have a gap. Two of them will eventually have a gap on the same day and you will be glad the third one exists.
The invoice incident was not a proxy failure. Not a Server Component failure. It was a missing data layer. The first two layers were correct. The third one did not exist.
That is the auth system most Next.js tutorials hand you. One layer. The other two are never mentioned.
This post is part of the Next.js 16 Auth: From Broken to Production-Safe series. The next post covers building the complete proxy.ts gate with working code: JWT verification with jose, the role routing config, the matcher pattern that most setups get wrong, and why the header forwarding direction breaks silently when you get it backwards.
The full implementation with all three layers, token refresh, Route Handler protection, and the complete Auth Guard client-side setup is at shubhra.dev/tutorials/nextjs-16-authentication-3-layer-security.
Has anyone else hit a data layer gap like this? I kept assuming the proxy covered everything until it very clearly did not. Curious how common this actually is across different projects.
Top comments (0)