Next.js 16 Authentication: The 3-Layer Security Model That Catches What proxy.ts Misses
- Author
- Shubhra Dev
- Date
- Reading time
- 23 min read
- Difficulty
- Advanced
On this page
A few months ago I thought the auth on a client project was solid.
The protected routes were protected. Unauthenticated users got redirected. Tokens expired correctly. I had tested it three different ways and everything held. I was confident enough to stop thinking about it.
Then I was doing a final pre-release review before handing it over and found something. A user with a valid session, the right role, the correct JWT, could 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 that I had only built one layer and called it done.
That afternoon changed how I think about Next.js 16 authentication entirely.
The mistake was treating proxy.ts as the security layer. It is a routing layer. Fast, essential, and genuinely useful, but one piece of a system, not the whole system. The moment you treat it as the whole system, you have a single point of failure at the network edge and nothing behind it when something goes wrong.
This tutorial covers how to build Next.js 16 authentication the right way: three independent checks that each work even when the others have a gap. By the end, you will have a complete setup with proxy.ts as the fast gate, Server Components doing real authorization per page, the data layer scoping every query by user, and the Auth Guard snippet wiring together the client side.
Before we touch any code, there is a concept you need in your head first.
This is Part 8 of the Next.js Deep Dive series. Earlier parts cover the App Router, file conventions, caching migration, and performance. The most relevant to this tutorial are Part 4 (App Router file conventions), Part 6 (the
middleware.ts→proxy.tsrename), and Part 7 (PPR with auth-gated pages).
The Mental Model Most Auth Tutorials Skip
Search for Next.js authentication tutorials and you will find the same thing in almost all of them: put a token check in proxy.ts (or its predecessor middleware.ts), redirect unauthenticated users, done.
That is one layer. It is the least powerful of the three.
Here is the full picture of what a complete Next.js 16 authentication system looks like.
The proxy runs at the network boundary before anything renders. It reads the session cookie, verifies the JWT, checks the role, and either redirects the user or sends the request forward. It is fast and cheap because it runs before any React component renders. It catches unauthenticated users early, which is great for UX. But the proxy only sees URL patterns. Once it decides a user can access /dashboard, its job is done. It cannot see what specific data that user is requesting inside the dashboard.
Server Components run when the page renders. This is where route-level authorization belongs. Who is this user? Are they allowed to see this specific page? Do they have the right permissions for this specific action? This check is independent of the proxy. Even if a proxy misconfiguration lets a request slip through, the Server Component catches it.
The data layer runs when data is fetched. Every query is scoped to the authenticated user. Ownership checks live inside the data functions. Even if both the proxy and the Server Component had a bug simultaneously, a user would still only be able to see data that belongs to them.
The incident I described above was a data layer problem. The proxy was correct. The Server Component was correct. The data function returned records to anyone who provided an ID, without checking whether the requesting user owned those records. One gap at layer three was all it took.
Throughout this tutorial, that three-part structure is what we are building. Not just the proxy, but all three. Let us start.
What Changed in Next.js 16 (And Why It Matters Specifically for Auth)
If you have read Part 6 of this series, you already know that middleware.ts was renamed to proxy.ts as a breaking change. That tutorial covers the rename in the context of the caching migration. What it does not cover, because that is not what Part 6 is for, is what the rename means specifically for auth.
Two things changed that matter for authentication.
The proxy now runs on the Node.js runtime. The old middleware.ts defaulted to the 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 the Node.js runtime by default. This is a genuine improvement for auth because you get full crypto support. jose works completely. Any standard JWT library works. No workarounds, no algorithm restrictions.
middleware.ts is deprecated in Next.js 16. It still works for Edge runtime during the deprecation period, but the Next.js team says to avoid relying on it. proxy.ts does not support Edge runtime. It runs on Node.js only and you cannot configure that. For authentication, use proxy.ts with the Node.js runtime. The full crypto support alone makes it worth it.
The migration from middleware.ts to proxy.ts is covered in Part 4 and Part 6. If you have already done that, move forward. If not, there are two ways to run the migration. Use the full upgrade codemod if you want to handle all Next.js 16 changes at once:
npx @next/codemod@canary upgrade latestOr, if you only want to migrate the middleware file without touching anything else:
npx @next/codemod@latest middleware-to-proxy .After running the codemod, verify manually that middleware.ts no longer exists at your project root, that proxy.ts exists in its place, and that the exported function is named proxy not middleware. The Next.js team recommends keeping only proxy.ts in your project root. While middleware.ts still works for Edge runtime use cases, having both files can lead to confusion about which one is handling a given request.
Step 1: The Storage Decision Everything Else Depends On
In production, prefer httpOnly cookies set from your API response to prevent JavaScript access. The client-side cookie storage used here is for the auth state sync pattern; the proxy reads whatever cookie the browser sends.
The first real decision in Next.js 16 authentication is where your tokens live. This is not a preference. It determines whether your proxy can see the session at all.
proxy.ts runs on the server before anything renders. It cannot read localStorage. It cannot read React state. The only auth signal it can see is a cookie. If your tokens are in localStorage, the proxy is completely blind. It will redirect every single request including fully authenticated users to your login page, because from the proxy's point of view there is no session.
The components in the next steps - AuthProvider, useAuth, RouteGuard, withAuth, and AuthError - are from the Auth Guard snippet, not built into Next.js. You can implement the same patterns yourself with React Context and useState, or grab the full snippet to drop into your lib/ folder.
The Auth Guard snippet supports three storage modes. Here is what each one means for your architecture:
localStorage is the default. Works perfectly for client-side auth with useAuth, RouteGuard, and withAuth. But the proxy cannot see those tokens. If you want server-side route protection through proxy.ts, this option does not work for that.
cookie stores tokens in a cookie with samesite=strict and secure on HTTPS. The proxy reads this cookie on every request. Your client-side context reads the same cookie. Both sides of the app see the same session state. This is what we are using in this tutorial.
memory keeps tokens in memory only. Useful for testing and SSR-only flows. Not relevant here.
Set up your root layout with storage="cookie":
// app/layout.tsx
import { AuthProvider } from "@/lib/auth-guard";
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body>
<AuthProvider
apiUrl="/api/auth"
storage="cookie"
sessionCheckInterval={60000}
onSessionExpire={() => {
// Fires on the client when a refresh attempt fails
if (typeof window !== "undefined") {
window.location.href = "/login?reason=expired";
}
}}
onLogin={(user) => {
// Optional: analytics, tracking, anything that runs after login
console.log("User logged in:", user.email);
}}
>
{children}
</AuthProvider>
</body>
</html>
);
}With storage="cookie", the snippet writes tokens to a cookie named auth_tokens. That is the exact cookie name the proxy template reads. Both sides are already aligned.
Step 2: Building the proxy.ts Gate
Create proxy.ts at your project root, at the same level as the app folder. Delete middleware.ts if it exists there.
The proxy has one job: decide whether a request should go through or be redirected. Fast, lightweight, no database calls.
// proxy.ts
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
import { jwtVerify } from "jose";
// npm install jose
// jose works fully in Node.js runtime with no workarounds needed
const JWT_SECRET = new TextEncoder().encode(process.env.JWT_SECRET!);
// Routes that never require authentication
const PUBLIC_ROUTES = [
"/login",
"/register",
"/forgot-password",
"/reset-password",
"/api/auth/login",
"/api/auth/refresh",
"/api/auth/logout",
"/",
"/about",
"/pricing",
"/blog",
];
// Route prefixes and the roles that may access them
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;
// Let public routes through without any checks
if (PUBLIC_ROUTES.some((route) => pathname.startsWith(route))) {
return NextResponse.next();
}
// The cookie name matches what AuthProvider writes with storage="cookie"
const tokenCookie = request.cookies.get("auth_tokens")?.value;
if (!tokenCookie) {
// No session, send to login and preserve where they were going
const loginUrl = new URL("/login", request.url);
loginUrl.searchParams.set("redirect", pathname);
return NextResponse.redirect(loginUrl);
}
try {
const tokens = JSON.parse(tokenCookie);
// Full Node.js runtime means jwtVerify works with no limitations
const { payload } = await jwtVerify(tokens.accessToken, JWT_SECRET);
const role = payload.role as string;
// Check role-based access by route prefix
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));
}
}
// Forward verified identity to the page via request headers.
// Server Components read the incoming request headers, not response headers.
// This is the correct pattern per the official proxy.ts docs.
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) ?? "");
// These headers are trusted only because proxy.ts injects them before
// the request reaches application code. Never trust x-user-* headers
// from client requests.
return NextResponse.next({
request: { headers: requestHeaders },
});
} catch {
// Token is expired, malformed, or tampered with
// All three cases go back to login, no information leakage
const loginUrl = new URL("/login", request.url);
loginUrl.searchParams.set("redirect", pathname);
return NextResponse.redirect(loginUrl);
}
}
export const config = {
matcher: [
// Run on all paths except static files, images, and browser metadata
// Without this, the proxy runs on every CSS and JS file request
"/((?!_next/static|_next/image|favicon.ico|sitemap.xml|robots.txt|.*\\.png$|.*\\.jpg$|.*\\.webp$|.*\\.svg$|.*\\.ico$).*)",
],
};Three decisions in this proxy worth understanding.
The matcher is the most common place auth setups break. If you do not exclude static assets, the proxy runs on every CSS file, every JavaScript bundle, every image request. The negative lookahead pattern above keeps the proxy focused on actual routes.
The x-user-* headers forward the verified identity downstream. Notice the headers are set on the request, not the response. Server Components call headers() which returns the incoming request headers. Setting them on NextResponse.next({ request: { headers } }) is what makes them visible to your pages. Setting them directly on the response object sends them to the browser instead, which means your Server Components will never see them.
The try/catch handles three failure modes with one redirect: a cookie that exists but is not valid JSON, a JWT that is structurally broken, and a JWT that has expired. All three end up back at login without leaking information about which failure occurred.
Step 3: Server Components, The Authorization Layer
Here is the layer that was missing from my app when I found the permissions hole.
The proxy decided the user could access /dashboard. But that decision was based on URL pattern and role. It knew nothing about which specific invoice the user was requesting inside that dashboard. Server Components are where you make the specific authorization decision: is this particular user allowed to render this particular page with this particular data.
Reading the identity the proxy forwarded:
// app/admin/page.tsx
import { headers } from "next/headers";
import { redirect } from "next/navigation";
import { AdminDashboard } from "@/components/admin-dashboard";
import { getAdminStats } from "@/lib/data";
export default async function AdminPage() {
const headerStore = await headers();
const userId = headerStore.get("x-user-id");
const userRole = headerStore.get("x-user-role");
// This check is independent of the proxy
// A proxy misconfiguration, a matcher gap, a new route someone added
// without updating ROLE_ROUTES, any of those get caught here
if (!userId || userRole !== "admin") {
redirect("/unauthorized");
}
// Safe to fetch data now, userId is verified at two independent points
const stats = await getAdminStats(userId);
return <AdminDashboard stats={stats} userId={userId} />;
}For pages that need permission-level checks rather than role-level checks, the pattern goes one step further. The proxy only carries role in the JWT because roles are stable. Permissions change more frequently and are more granular, so they come from the database:
// app/dashboard/billing/page.tsx
import { headers } from "next/headers";
import { redirect } from "next/navigation";
import { getUserPermissions, getUserInvoices } from "@/lib/data";
import { BillingView } from "@/components/billing-view";
export default async function BillingPage() {
const headerStore = await headers();
const userId = headerStore.get("x-user-id");
if (!userId) {
redirect("/login");
}
// Role came from the JWT, fast, no DB call
// Permissions need a DB call because they change more often
const permissions = await getUserPermissions(userId);
if (!permissions.includes("billing:read")) {
redirect("/unauthorized");
}
const invoices = await getUserInvoices(userId);
return <BillingView invoices={invoices} />;
}The split makes sense once you see it. Roles are coarse-grained and stable enough to embed in a JWT. Permissions are fine-grained and can change when you update access settings. You do not want to wait for the JWT to expire to reflect that change. Roles in the proxy, permissions in the Server Component.
Step 4: The Data Layer, The Backstop That Saved Me
This is the layer that would have caught the invoice problem even if the first two layers had both had gaps on the same day.
The principle is simple: every data function that returns user-specific data takes a userId parameter and uses it to scope the query. Not as a convenience. As a security control.
// lib/data.ts
// Every query is scoped to the requesting user
// Even with a valid session and correct role, you only see your own data
export async function getUserInvoices(userId: string) {
return db.query(
`SELECT id, amount, status, created_at, description
FROM invoices
WHERE user_id = $1
ORDER BY created_at DESC`,
[userId],
);
}
// Ownership check built into the query
// Knowing a project ID is not enough, you must also own it
export async function getProjectById(projectId: string, userId: string) {
const project = await db.query(
`SELECT * FROM projects
WHERE id = $1 AND owner_id = $2`,
[projectId, userId],
);
// Returns null if the project exists but this user does not own it
// Treating "not found" and "not authorized" identically avoids
// leaking information about whether resources exist
if (!project) {
throw new Error("Not found");
}
return project;
}
// Admin functions verify role at the database level before querying
export async function getAdminStats(userId: string) {
const user = await db.query("SELECT role FROM users WHERE id = $1", [userId]);
if (!user || user.role !== "admin") {
throw new Error("Unauthorized");
}
return db.query("SELECT * FROM admin_stats_view");
}Treating "not found" and "not authorized" as the same response is intentional. If you return different errors for each, you give an attacker a way to enumerate which resource IDs exist in your system. Returning Not found in both cases closes that information leak.
The data layer catches what the other two miss. It cannot be misconfigured into not running. It cannot have a matcher gap. It cannot be missing from a new route added last week. The authorization is inside the query itself.
Step 5: Client-Side Auth With the Auth Guard Snippet
The three server-side checks handle security. The client side handles experience: loading states, permission-aware navigation, the login form, and the silent session refresh that happens in the background while a user is actively working.
AuthProvider in the root layout makes auth state available everywhere through the useAuth hook:
"use client";
import { useAuth } from "@/lib/auth-guard";
export function Navbar() {
const { user, hasRole, hasPermission, logout } = useAuth();
if (!user) {
return (
<nav>
<a href="/login">Sign in</a>
<a href="/register">Create account</a>
</nav>
);
}
return (
<nav>
<span>Hi, {user.name ?? user.email}</span>
{hasRole(["admin"]) && <a href="/admin">Admin Panel</a>}
{hasRole(["admin", "moderator"]) && <a href="/moderator">Moderation</a>}
{hasPermission("billing:manage") && <a href="/billing">Billing</a>}
<button onClick={logout}>Sign out</button>
</nav>
);
}RouteGuard adds a second client-side check on top of the server checks you already have. It protects against edge cases in client-side navigation where a user might transition to a page faster than the server can respond:
// app/settings/page.tsx
"use client";
import { RouteGuard } from "@/lib/auth-guard";
import { SettingsPanel } from "@/components/settings-panel";
export default function SettingsPage() {
return (
<RouteGuard
allowedRoles={["admin", "user"]}
requiredPermissions={["settings:read"]}
permissionMode="all"
loadingComponent={<SettingsSkeleton />}
unauthorizedComponent={
<div className="p-8 text-center">
<p>You do not have permission to view settings.</p>
</div>
}
>
<SettingsPanel />
</RouteGuard>
);
}RouteGuard always waits for isLoading to flip to false before making any decision. This is what prevents the visible flicker where protected content briefly appears to unauthenticated users while the auth state is still loading from the cookie on initial mount.
If you prefer to keep the guard config next to the component rather than in JSX, withAuth does the same thing:
// app/dashboard/page.tsx
import { withAuth } from "@/lib/auth-guard";
import { Dashboard } from "@/components/dashboard";
function DashboardPage() {
return <Dashboard />;
}
export default withAuth(DashboardPage, {
allowedRoles: ["admin", "user", "moderator"],
loadingComponent: <DashboardSkeleton />,
});Both RouteGuard and withAuth use the same underlying auth context from AuthProvider. The difference is syntax only.
Step 6: The Login Form That Handles Every Error State
The Auth Guard snippet's login() function throws typed AuthError objects on failure. Your form catches them and maps each error code to a message a user can actually understand:
// app/login/page.tsx
"use client";
import { useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { useAuth, AuthError } from "@/lib/auth-guard";
export default function LoginPage() {
const { login } = useAuth();
const router = useRouter();
const searchParams = useSearchParams();
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setError(null);
setLoading(true);
const form = e.currentTarget;
const email = (form.elements.namedItem("email") as HTMLInputElement).value;
const password = (form.elements.namedItem("password") as HTMLInputElement)
.value;
const remember = (form.elements.namedItem("remember") as HTMLInputElement)
.checked;
try {
await login(email, password, remember);
// After login, send users back to the page they were trying to reach
const redirectTo = searchParams.get("redirect") ?? "/dashboard";
router.push(redirectTo);
} catch (err) {
if (err instanceof AuthError) {
const messages: Record<string, string> = {
INVALID_CREDENTIALS:
"That email and password combination is not right.",
ACCOUNT_LOCKED:
"This account is locked. Contact support to unlock it.",
ACCOUNT_INACTIVE: "This account is not active yet.",
TOO_MANY_ATTEMPTS:
"Too many failed attempts. Wait a few minutes and try again.",
};
setError(
messages[err.code] ?? "Something went wrong. Please try again.",
);
} else {
setError("Something went wrong. Please try again.");
}
} finally {
setLoading(false);
}
}
const expiredSession = searchParams.get("reason") === "expired";
return (
<main className="flex min-h-screen items-center justify-center p-4">
<form onSubmit={handleSubmit} className="w-full max-w-sm space-y-4">
<h1 className="text-2xl font-semibold">Sign in</h1>
{expiredSession && (
<p className="rounded bg-amber-50 p-3 text-sm text-amber-700">
Your session expired. Sign in again to continue.
</p>
)}
{error && (
<p
className="rounded bg-red-50 p-3 text-sm text-red-700"
role="alert"
>
{error}
</p>
)}
<div className="space-y-1">
<label htmlFor="email" className="text-sm font-medium">
Email
</label>
<input
id="email"
name="email"
type="email"
required
autoComplete="email"
className="w-full rounded border px-3 py-2"
/>
</div>
<div className="space-y-1">
<label htmlFor="password" className="text-sm font-medium">
Password
</label>
<input
id="password"
name="password"
type="password"
required
autoComplete="current-password"
className="w-full rounded border px-3 py-2"
/>
</div>
<label className="flex items-center gap-2 text-sm">
<input name="remember" type="checkbox" />
Keep me signed in for 30 days
</label>
<button
type="submit"
disabled={loading}
className="w-full rounded bg-indigo-600 px-4 py-2 text-white disabled:opacity-60"
>
{loading ? "Signing in..." : "Sign in"}
</button>
</form>
</main>
);
}The remember flag controls the cookie lifetime. When it is true, the snippet sets a 30-day persistent cookie. When it is false, the cookie has no expiry attribute and the browser clears it when it closes. The snippet handles all of that internally.
Step 7: Token Refresh That Users Never See
Authentication breaks quietly when token refresh is wrong. A user is in the middle of writing something. Their access token expires. The next request fails. They lose their work.
The Auth Guard snippet prevents this in two ways.
It polls every 60 seconds. When the polling interval fires, the snippet checks whether the access token is within 5 minutes of expiry. If it is, refreshSession() calls your refresh endpoint, gets a new token, and updates the cookie. The user sees nothing. It just works.
It also checks on initialization. Before isLoading flips to false, before anything renders, the snippet checks stored tokens. If the token is already close to expiry or already expired, it refreshes before showing any content. A user who closed the browser and came back does not see a flash of protected content before being redirected.
Your refresh endpoint needs to return the same shape as your login endpoint:
// app/api/auth/refresh/route.ts
import { NextResponse } from "next/server";
import { jwtVerify, SignJWT } from "jose";
const REFRESH_SECRET = new TextEncoder().encode(
process.env.JWT_REFRESH_SECRET!,
);
const ACCESS_SECRET = new TextEncoder().encode(process.env.JWT_SECRET!);
export async function POST(request: Request) {
try {
const { refreshToken } = await request.json();
// Verify the refresh token first
const { payload } = await jwtVerify(refreshToken, REFRESH_SECRET);
// Issue a fresh access token with the same claims
const newAccessToken = await new SignJWT({
sub: payload.sub,
role: payload.role,
email: payload.email,
permissions: payload.permissions ?? [],
})
.setProtectedHeader({ alg: "HS256" })
.setIssuedAt()
.setExpirationTime("15m")
.sign(ACCESS_SECRET);
return NextResponse.json({
user: {
id: payload.sub,
email: payload.email as string,
role: payload.role as string,
permissions: (payload.permissions as string[]) ?? [],
},
tokens: {
accessToken: newAccessToken,
refreshToken,
expiresAt: Date.now() + 15 * 60 * 1000,
},
});
} catch {
return NextResponse.json(
{ error: "Invalid or expired refresh token" },
{ status: 401 },
);
}
}The expiresAt field is a Unix millisecond timestamp. The Auth Guard snippet reads this to know when to trigger the next refresh. If it is missing or wrong, the snippet treats the token as immediately expired and tries to refresh on every poll.
Step 8: Protecting Route Handlers
Route Handlers need their own auth checks. They are direct HTTP endpoints. Any client can call them: your app, a mobile app, a cURL command. The proxy runs before page renders, but external API callers may have their own valid tokens and bypass the page flow entirely.
A small utility that makes auth checks in Route Handlers consistent:
// lib/auth-server.ts
import { headers } from "next/headers";
import { NextResponse } from "next/server";
type AuthUser = {
userId: string;
role: string;
email: string;
};
// Use in Server Components to read the forwarded identity
export async function getAuthUser(): Promise<AuthUser | null> {
const h = await headers();
const userId = h.get("x-user-id");
const role = h.get("x-user-role");
const email = h.get("x-user-email") ?? "";
if (!userId || !role) return null;
return { userId, role, email };
}
// Use in Route Handlers, returns a Response if the check fails
export async function requireAuth(
requiredRole?: string,
): Promise<AuthUser | Response> {
const user = await getAuthUser();
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
if (requiredRole && user.role !== requiredRole) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
return user;
}Using it:
// app/api/invoices/route.ts
import { requireAuth } from "@/lib/auth-server";
import { getUserInvoices } from "@/lib/data";
export async function GET() {
const auth = await requireAuth();
// requireAuth returns a Response object when auth fails
// Returning it directly sends the error response to the caller
if (auth instanceof Response) return auth;
// auth is AuthUser here, TypeScript knows this
const invoices = await getUserInvoices(auth.userId);
return Response.json(invoices);
}
// app/api/admin/users/route.ts
import { requireAuth } from "@/lib/auth-server";
import { getAllUsers } from "@/lib/data";
export async function GET() {
const auth = await requireAuth("admin");
if (auth instanceof Response) return auth;
const users = await getAllUsers();
return Response.json(users);
}The instanceof Response pattern works because TypeScript narrows the type after that check. Below it, auth is definitely AuthUser and TypeScript knows it.
What All Three Layers Look Like on One Request
Here is what actually happens when an admin loads /admin/dashboard, from the request arriving to the component rendering:
Request: GET /admin/dashboard
proxy.ts
Reads auth_tokens cookie
Parses tokens JSON
Calls jwtVerify(accessToken, JWT_SECRET)
Reads role from payload
Checks /admin => ['admin'] in ROLE_ROUTES
Role is not admin? Redirect to /unauthorized
Role is admin? Set x-user-id, x-user-role on request headers, continue
app/admin/page.tsx (Server Component)
Reads x-user-id from headers, no JWT re-verification needed
Reads x-user-role from headers
Role is not admin? redirect('/unauthorized')
Role is admin? Call getAdminStats(userId)
lib/data.ts: getAdminStats(userId)
Queries users table to verify admin role at DB level
Role mismatch at DB level? throw new Error('Unauthorized')
Role confirmed? Query admin_stats_view and return
Component renders with verified, scoped data
Three independent checks. A bug in any single one, a matcher gap, a missed role in ROLE_ROUTES, a new route that skips the Server Component check, does not open the door because the other two are still standing.
That is the difference between the auth that looked correct and the auth that actually was.
Before You Ship: The Checklist
These are the things that work perfectly in local development and break in production. All of them have happened.
Test an actual redirect in the deployed environment. Not in local dev. After deployment. Hit a protected route with no cookie, with an expired token, and with a valid token but the wrong role. Watch what happens. The build passing is not evidence that the redirects fire. Only testing the redirects is.
Check that JWT_SECRET exists in your deployment environment. It is always in .env.local. It is easy to forget in staging or production environment variables. The proxy JWT verification throws a cryptic error if it is missing.
Verify storage="cookie" on AuthProvider matches what the proxy reads. If AuthProvider is using localStorage (the default if you do not specify), the proxy will redirect every request including authenticated users. The auth_tokens cookie will not exist.
Check the matcher for new routes. Every time you add a route that should be protected, check that it falls inside the matcher pattern and appears in ROLE_ROUTES or that the proxy's default behavior handles it correctly. The most common auth gap is a new route that was added after the proxy config was written.
Check expiresAt in your token response. The Auth Guard snippet reads this field to schedule token refresh. If it is missing or set to a past timestamp, the snippet treats every token as expired and calls the refresh endpoint on every polling interval.
Quick Reference
// proxy.ts setup | Layer 1
export async function proxy(request: NextRequest) {
const tokenCookie = request.cookies.get('auth_tokens')?.value
if (!tokenCookie) return redirect('/login')
const { payload } = await jwtVerify(JSON.parse(tokenCookie).accessToken, SECRET)
const requestHeaders = new Headers(request.headers)
requestHeaders.set('x-user-id', payload.sub as string)
requestHeaders.set('x-user-role', payload.role as string)
return NextResponse.next({ request: { headers: requestHeaders } })
}
// Server Component check | Layer 2
export default async function ProtectedPage() {
const h = await headers()
const userId = h.get('x-user-id')
if (!userId) redirect('/login')
// fetch data scoped to userId
}
// Data function | Layer 3
export async function getData(userId: string) {
return db.query(
'SELECT * FROM records WHERE user_id = $1',
[userId]
)
}
// Root layout
<AuthProvider storage="cookie" apiUrl="/api/auth">
{children}
</AuthProvider>
// Client, checking state and permissions
const { user, hasRole, hasPermission, logout } = useAuth()
// Client, declarative route protection
<RouteGuard allowedRoles={['admin']} requiredPermissions={['users:write']}>
<AdminContent />
</RouteGuard>
// Client, HOC form
export default withAuth(Page, { allowedRoles: ['user'] })
// Route Handler protection
const auth = await requireAuth('admin')
if (auth instanceof Response) return auth
// auth is AuthUser below this lineContinue Learning in This Series
This is Part 8 of the Next.js Deep Dive series. Here is where each part fits around what you just read.
Part 4: Mastering the Next.js App Router covers proxy.ts as a file convention alongside all other App Router special files. If you want to understand how proxy.ts fits into the full file system, that is the reference.
Part 6: Next.js 16 Cache Components covers the middleware.ts to proxy.ts rename as a breaking change in the caching migration context. It also covers cookies() and headers() being async now, which you saw used in this tutorial.
Part 7: Performance Optimization Checklist covers combining the PPR static shell pattern with protected pages so your authenticated pages are still fast. The Suspense boundary pattern you have seen throughout this series is what makes that possible.
Further Reading
Next.js proxy.ts Documentation. Official file convention reference including the matcher config and runtime behavior.
Next.js Upgrading to Version 16. The complete official breaking changes list including the middleware.ts rename and what the codemod covers.
jose library. The JWT library used in this tutorial. Works fully in Node.js runtime without any workarounds.
The Auth Guard snippet snippet is the client-side half of everything in Steps 5, 6, and 7: AuthProvider, useAuth, RouteGuard, withAuth, AuthError, cross-tab logout sync, automatic session polling, and the full TypeScript types for every interface used in this tutorial.
Next.js 16 Auth Quiz. Test whether the details actually stuck. 15 questions on the same bugs and edge cases covered in this tutorial.