The Senior Frontend Engineering Blueprint, Part 5: Edge Proxies, Auth.js Credentials and a Server-Side Data Access Layer
A redirect from the edge feels like security, but an attacker can skip it entirely by calling your Server Action directly. Here is the request pipeline Next.js 16 actually runs, a Credentials-based Auth.js v5 setup with Prisma and RBAC, and the data access layer that is the only boundary that actually holds.
A redirect that fires before your page renders feels like security. It isn't. If your only authentication check lives in an edge file that inspects a cookie and bounces the browser back to /login, an attacker who already knows the internal action ID doesn't need the browser at all — they can POST straight to the endpoint your Server Action compiles down to, skip the redirect, and never see your check. This is the question senior Next.js interviews keep circling back to: where does access control actually live, and why is the obvious answer wrong?
This is Part 5 of the Blueprint series — a continuation of the enterprise-grade platform we've been building since Part 1. Before we get into it: if you're looking for how to wire Auth.js v5 to a third-party identity provider, that's a different, already-answered question — see Self-Hosted SSO: Wiring authentik to Next.js 16 and Auth.js v5 for federating identity out to a self-hosted OIDC provider, including the logout mechanics. This article is the opposite shape of problem: an app that owns its own user table, authenticates against it directly with a Credentials provider, and has to decide — correctly — where the security boundary actually sits.
The request-interception pipeline
Every request to a modern Next.js app passes through a narrow perimeter file before any Server Component, Route Handler, or Server Action executes:
Incoming Request
│
▼
┌─────────────────────────────────────────────────────────────┐
│ Perimeter Interceptor (proxy.ts) │
│ - Non-blocking optimistic JWT inspection (Cookie header) │
│ - Request header injection (x-user-id, x-trace-id) │
│ - URL rewrites (A/B testing, multi-tenant subdomains) │
│ - Fast redirects for obviously unauthenticated routes │
└──────────────┬──────────────────────────────────────────────┘
│
Pass / Next()
▼
┌─────────────────────────────────────────────────────────────┐
│ Next.js App Router │
│ - Layout tree resolution ((marketing) vs (dashboard)) │
│ - React Server Components (RSC payload generation) │
│ - Data Access Layer — the authoritative database security gate│
└─────────────────────────────────────────────────────────────┘Until Next.js 16, this file was called middleware.ts, and it ran exclusively on an isolated Edge runtime — which meant bundling polyfills for ordinary Node APIs, and it quietly encouraged people to put database session lookups in there, which exhausts connection pools under load and adds latency to every single request, including ones that don't need it.
Next.js 16 deprecated middleware.ts outright and renamed the convention to proxy.ts, formalising its contract as a lightweight routing proxy: fast, low-IO tasks only — header mutation, optimistic cookie inspection, internal rewrites. And here's the detail that catches people who still think of this file as "the edge layer" out of habit: Next.js's own documentation states plainly that "proxy defaults to using the Node.js runtime." The isolated V8 Edge sandbox that made middleware.ts awkward to work with is gone by default. You get the full Node.js standard library here now — which makes the old excuse for keeping this file starved of logic disappear, and makes the discipline of keeping it starved of logic a deliberate architectural choice rather than a runtime limitation.
A production-grade proxy.ts
// proxy.ts
import { NextRequest, NextResponse } from "next/server";
// Exclude static assets, images, and public manifests from the perimeter entirely
export const config = {
matcher: [
"/((?!_next/static|_next/image|favicon.ico|sitemap.xml|robots.txt|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)",
],
};
const PUBLIC_ROUTES = new Set(["/", "/login", "/register", "/api/auth"]);
const AUTH_ONLY_ROUTES = new Set(["/login", "/register"]);
export function proxy(req: NextRequest): NextResponse {
const { nextUrl } = req;
const pathname = nextUrl.pathname;
// Correlation ID for distributed tracing across RSC and the DAL
const traceId = crypto.randomUUID();
const requestHeaders = new Headers(req.headers);
requestHeaders.set("x-trace-id", traceId);
// Optimistic check: presence of a session cookie only, never a DB lookup
const sessionToken =
req.cookies.get("__Secure-authjs.session-token")?.value ??
req.cookies.get("authjs.session-token")?.value;
const isAuthenticated = Boolean(sessionToken);
const isPublicRoute = PUBLIC_ROUTES.has(pathname) || pathname.startsWith("/api/auth");
const isAuthOnlyRoute = AUTH_ONLY_ROUTES.has(pathname);
// Authenticated user hitting /login or /register -> send them to the dashboard
if (isAuthenticated && isAuthOnlyRoute) {
return NextResponse.redirect(new URL("/dashboard", req.url));
}
// Unauthenticated user hitting a private route -> bounce to /login with a return URL
if (!isAuthenticated && !isPublicRoute) {
const loginUrl = new URL("/login", req.url);
if (pathname !== "/") {
loginUrl.searchParams.set("callbackUrl", encodeURI(pathname));
}
return NextResponse.redirect(loginUrl);
}
// Multi-tenant subdomain rewriting: tenant.domain.com -> /_tenants/tenant
const hostname = req.headers.get("host") ?? "";
const rootDomain = process.env.NEXT_PUBLIC_ROOT_DOMAIN ?? "localhost:3000";
if (hostname.includes(".") && !hostname.startsWith("www.") && hostname !== rootDomain) {
const subdomain = hostname.replace(`.${rootDomain}`, "");
return NextResponse.rewrite(
new URL(`/_tenants/${subdomain}${pathname}${nextUrl.search}`, req.url),
{ request: { headers: requestHeaders } },
);
}
return NextResponse.next({ request: { headers: requestHeaders } });
}Two failure modes worth knowing before you ship this:
- The infinite redirect loop. It happens when
/loginitself gets swept up by the "unauthenticated → redirect to/login" branch, or your matcher forgets to exclude/api/auth/*. Always isolate auth endpoints and public assets in their own condition, before the generic gate. - The static-bypass trap. If you put your optimistic auth guard inside a Server Component instead of the proxy, calling
cookies()orheaders()there forces Next.js to opt that route out of static rendering entirely, at build time, for every visitor — authenticated or not. Doing the optimistic check at the perimeter lets protected pages stay statically rendered (or partially prerendered) while unauthorised requests get bounced before they ever reach the renderer.
Auth.js v5, Prisma, and a typed session
Add the Auth.js models to the schema from Part 1:
model Account {
id String @id @default(cuid())
userId String
type String
provider String
providerAccountId String
refresh_token String? @db.Text
access_token String? @db.Text
expires_at Int?
token_type String?
scope String?
id_token String? @db.Text
session_state String?
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([provider, providerAccountId])
@@index([userId])
}
model Session {
id String @id @default(cuid())
sessionToken String @unique
userId String
expires DateTime
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId])
}
model VerificationToken {
identifier String
token String @unique
expires DateTime
@@unique([identifier, token])
}Then run npx prisma migrate dev --name add_authjs_models.
Auth.js's default Session and User types don't know your app has roles. Module augmentation fixes that everywhere at once, rather than casting at every call site:
import { type Role } from "@prisma/client";
import { type DefaultSession } from "next-auth";
import "next-auth/jwt";
declare module "next-auth" {
interface Session {
user: {
id: string;
role: Role;
} & DefaultSession["user"];
}
interface User {
role: Role;
}
}
declare module "next-auth/jwt" {
interface JWT {
id: string;
role: Role;
}
}Now the Credentials provider itself, with the Prisma adapter and a JWT session strategy — JWT is the mandatory choice here, since a database-lookup session strategy would put the exact I/O cost we just excluded from the proxy right back into it:
import { PrismaClient } from "@prisma/client";
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined;
};
export const prisma =
globalForPrisma.prisma ??
new PrismaClient({
log: process.env.NODE_ENV === "development" ? ["query", "error", "warn"] : ["error"],
});
if (process.env.NODE_ENV !== "production") {
globalForPrisma.prisma = prisma;
}import NextAuth from "next-auth";
import { PrismaAdapter } from "@auth/prisma-adapter";
import Credentials from "next-auth/providers/credentials";
import { prisma } from "@/lib/prisma";
import { z } from "zod";
import crypto from "node:crypto";
const LoginSchema = z.object({
email: z.string().email(),
password: z.string().min(8),
});
// Constant-time comparison to defend the credential check against timing attacks
function verifyPassword(provided: string, storedHash: string, salt: string): boolean {
const hash = crypto.pbkdf2Sync(provided, salt, 10000, 64, "sha512").toString("hex");
return crypto.timingSafeEqual(Buffer.from(hash), Buffer.from(storedHash));
}
export const { handlers, auth, signIn, signOut } = NextAuth({
adapter: PrismaAdapter(prisma),
session: {
strategy: "jwt",
maxAge: 30 * 24 * 60 * 60, // 30 days
},
pages: {
signIn: "/login",
error: "/login",
},
providers: [
Credentials({
name: "Credentials",
credentials: {
email: { label: "Email", type: "email" },
password: { label: "Password", type: "password" },
},
authorize: async (credentials) => {
const parsed = LoginSchema.safeParse(credentials);
if (!parsed.success) return null;
const { email, password } = parsed.data;
const user = await prisma.user.findUnique({ where: { email } });
if (!user) {
// Burn the same CPU time on a miss as on a hit, so timing can't reveal which emails exist
crypto.pbkdf2Sync(password, "static_salt_leak_protection", 10000, 64, "sha512");
return null;
}
// Swap in your real stored hash + salt lookup and call verifyPassword() here
return {
id: user.id,
name: user.name,
email: user.email,
role: user.role,
};
},
}),
],
callbacks: {
async jwt({ token, user }) {
// Runs once, on sign-in: persist custom claims into the encrypted JWT
if (user) {
token.id = user.id!;
token.role = user.role;
}
return token;
},
async session({ session, token }) {
// Forward the persisted claims into the session object every request reads
if (session.user) {
session.user.id = token.id;
session.user.role = token.role;
}
return session;
},
},
});Mount it in app/api/auth/[...nextauth]/route.ts:
import { handlers } from "@/auth";
export const { GET, POST } = handlers;The authoritative security gate
Here's the sentence worth writing on a sticky note over your monitor: the proxy is an optimistic UX guard, not an access-control boundary. It routes users to the right place quickly. It does not — cannot — stop a request that skips it.
Next.js Server Actions run as POST requests to a build-time-generated, opaque action ID. If an attacker already has that ID — from reading your client bundle, or from a previous legitimate response — they can call it directly over HTTP, with no browser, no cookie header your proxy would even see attached the way you expect, and no redirect to intercept. Your proxy never runs for that request in any way that changes the outcome, because the Server Action's own endpoint is what actually executes your mutation.
Security has to be enforced where the data access actually happens — the Data Access Layer, on the server, on every call, with no exceptions for internal trust:
import "server-only"; // Fails the build if this file is ever imported into client code
import { auth } from "@/auth";
import { cache } from "react";
import { redirect } from "next/navigation";
import { Role } from "@prisma/client";
/**
* Request-memoised session retrieval: multiple Server Components calling
* verifySession() within one render pass only run the token check once.
*/
export const verifySession = cache(async () => {
const session = await auth();
if (!session?.user?.id) {
redirect("/login");
}
return {
isAuth: true,
userId: session.user.id,
role: session.user.role,
};
});
export const assertRole = async (requiredRole: Role) => {
const session = await verifySession();
if (session.role !== requiredRole) {
throw new Error("UNAUTHORIZED: insufficient permission clearance");
}
return session;
};Every Server Action, every Route Handler, every Server Component that touches sensitive data calls verifySession() or assertRole() directly — never relies on the fact that the proxy "already checked." react's cache() means this costs nothing extra even when five Server Components on one page all need the same session.
Route topology: three layout trees, one URL space
Route groups — folders in parentheses — let each area of the app get its own layout tree without touching the public URL:
app/
├── (marketing)/ <- Public route group; parens are invisible in the URL
│ ├── layout.tsx <- Public header, landing nav, footer
│ ├── page.tsx <- Maps to '/'
│ └── pricing/page.tsx <- Maps to '/pricing'
│
├── (dashboard)/ <- Protected route group
│ ├── layout.tsx <- Authenticated sidebar, calls verifySession()
│ ├── template.tsx <- Re-mounts on every navigation; see below
│ ├── dashboard/page.tsx <- Maps to '/dashboard'
│ └── settings/page.tsx <- Maps to '/settings'
│
└── (auth)/ <- Minimal centred layout, no header/footer
├── login/page.tsx <- Maps to '/login'
└── register/page.tsx <- Maps to '/register'import { verifySession } from "@/lib/dal/auth";
import Link from "next/link";
import { signOut } from "@/auth";
export default async function DashboardLayout({
children,
}: {
children: React.ReactNode;
}) {
// The authoritative gate for the entire (dashboard) tree — not the proxy
const session = await verifySession();
return (
<div className="flex min-h-screen bg-neutral-950 text-neutral-200">
<aside className="w-64 border-r border-neutral-800 p-6">
<div className="text-sm font-bold tracking-wider text-white">WORKSPACE</div>
<div className="mt-2 text-xs text-neutral-500">User: {session.userId}</div>
<div className="mt-1 inline-block rounded bg-neutral-800 px-2 py-0.5 text-[10px] text-neutral-400">
Role: {session.role}
</div>
<nav className="mt-8 space-y-2">
<Link href="/dashboard" className="block rounded px-3 py-2 text-sm hover:bg-neutral-900">
Overview
</Link>
{session.role === "ADMIN" && (
<Link
href="/dashboard/admin"
className="block rounded px-3 py-2 text-sm text-red-400 hover:bg-red-950/30"
>
Admin console
</Link>
)}
</nav>
<form
action={async () => {
"use server";
await signOut({ redirectTo: "/login" });
}}
className="mt-12"
>
<button
type="submit"
className="w-full rounded border border-neutral-800 py-2 text-xs font-semibold text-neutral-400 hover:bg-neutral-900"
>
Terminate session
</button>
</form>
</aside>
<div className="flex-1 p-8">{children}</div>
</div>
);
}layout.tsx and template.tsx look interchangeable until you check what they preserve across a navigation, and this distinction comes up constantly in senior interviews:
| Capability | layout.tsx |
template.tsx |
|---|---|---|
| Component remounting | Preserved — no remount across sibling route navigations | Remounted — a fresh instance mounts on every transition |
| State retention | Retains React state (open sidebar, form drafts) | Resets state completely on navigation |
useEffect |
Doesn't re-fire on transitions within the same tree | Re-runs on every navigation |
| Typical use | Persistent shells, sidebars, stateful providers | Page-transition animation, scroll reset, per-page telemetry |
"use client";
import { useEffect } from "react";
import { usePathname } from "next/navigation";
export default function DashboardTemplate({
children,
}: {
children: React.ReactNode;
}) {
const pathname = usePathname();
useEffect(() => {
console.info(`[TELEMETRY] Navigated to: ${pathname}`);
}, [pathname]);
return <div className="animate-in fade-in duration-200">{children}</div>;
}Revoking a token you already gave out
A stateless JWT session is cheap to verify — no database round trip — and expensive to revoke, because there's nothing to delete. Three real strategies, in ascending order of how often production teams actually reach for them:
1. Sliding window re-issuance
Every request that arrives past the halfway point of the token's life
gets a freshly re-signed token with a new expiry. Simple, but a stolen
token stays valid until someone notices and rotates the signing secret.
2. Redis invalidation blacklist
Revoked tokens go into an in-memory store with a TTL matching the
token's remaining lifetime. The proxy checks it on every request —
which means you've reintroduced exactly the network round trip the
proxy model exists to avoid, so this only works with a cache fast
enough to disappear into the noise (sub-millisecond, co-located).
3. Hybrid short-lived access token + opaque refresh token (what production
teams actually run)
Keep the JWT lifespan short — 15 minutes. Pair it with an opaque
refresh token in an HTTP-only, SameSite=Strict cookie, backed by a
database row you CAN delete. Revocation means deleting that row; the
short-lived access token simply expires on its own within minutes.CSRF: what Server Actions get for free, and what they don't
A question worth having a precise answer for: does Next.js need explicit CSRF tokens for Server Actions? No, but with real conditions attached. Server Actions get two browser-enforced checks automatically:
- Origin header inspection. The framework validates that the incoming
Origin(orReferer) header matches the server's ownHostheader before running the action. - Opaque action-ID dispatch. A Server Action is only reachable via the internal, build-time-generated
Next-Actionhash — there's no predictable, guessable URL for an attacker's form to target.
What this protection does not cover: a plain Route Handler under app/api/.../route.ts. Those get no automatic Origin check on POST, PUT, or DELETE. If you're accepting cross-site webhooks or a standard REST payload through a Route Handler, verifying Origin yourself is on you.
Verification
npx prisma validate
pnpm tsc --noEmit
pnpm run buildA correct build should show / and /pricing as static (○), and /dashboard and /api/auth/[...nextauth] as dynamic (ƒ) — the split between the public marketing group and the session-gated dashboard group made visible in the build output itself.
The compressed version
middleware.tsis gone in Next.js 16;proxy.tsreplaces it, and — contrary to the mental model most people still carry — it runs on the Node.js runtime by default, not an isolated Edge sandbox.- The proxy is an optimistic UX router. It is not, and cannot be, your access-control boundary — a Server Action's opaque endpoint can be called directly, bypassing it entirely.
- The Data Access Layer, not the proxy and not even the layout, is where
verifySession()/assertRole()actually have to run — on every sensitive read and write, memoised per request withreact'scache(). layout.tsxpersists state across navigations;template.tsxdeliberately doesn't. Pick based on whether re-running effects on every transition is a feature or a bug for that piece of UI.- Server Actions get CSRF protection for free from Origin checks and opaque action IDs; plain Route Handlers do not — verify
Originyourself there.