In Next.js 16 a protected area has two layers. proxy.ts (the name of middleware since version 16) quickly checks the session cookie signature: a guest gets a 307 to /login?next=…, and a signed-in user without the required role gets a 403. The real protection is the Data Access Layer: every function that reads or changes data checks the session and the current role from the database itself, so neither a direct request, nor a stale cookie, nor a Server Action called without the page can get around it.
After this you will be able to
- lock down a section with an optimistic check in
proxy.ts: redirect a guest to sign-in with a return path, answer 403 to the wrong role - move session and role checks into a Data Access Layer (
requireUser,requireRole) and rely on it in pages and Server Actions - return the user to the original page after sign-in without an open redirect vulnerability
- verify the protection with Playwright tests: guest, signed-in user, wrong role, stale session
Know beforehand
Verified with
Next.js 16.4 (Cache Components)React 19.3jose 6.2TypeScript 5.9Node.js 24 LTS (24.18)Playwright 1.64
Checked against docs: October 12, 2026
Contents
The task and the threat model
The task: the /account dashboard is open to any signed-in user, while the /admin leads section is for the admin role only. Hiding the “Admin” link in the menu is not enough: the server responses themselves must be protected. Here is what someone without access tries:
- opens
/admindirectly, with no cookie or a forged one; - comes back with an old cookie issued while they still had the admin role;
- sends a POST to the delete-lead Server Action, skipping the page;
- slips
?next=https://evil.exampleinto the sign-in link to send the person to their own site after sign-in.
proxy.ts
- What the check sees
- The cookie signature and expiry, the role stored in the cookie; no database access
- Is it enough
- No. The docs call it optimistic and advise against making it the only line of defence
Layout (layout.tsx)
- What the check sees
- The same as a page
- Is it enough
- No. A layout does not re-render on client navigation and does not control whether child segments render
Data Access Layer
- What the check sees
- The session and the current role from the database, right next to the data
- Is it enough
- Yes, this is the main line
Server Action
- What the check sees
- The same, through the Data Access Layer
- Is it enough
- Required: an action is a public POST request
The session and the optimistic check in proxy.ts
The session is a signed JWT in an HttpOnly cookie, as in the Stateless Sessions section of the Next.js docs: the jose library, HS256, a secret from an environment variable. The token carries only the minimum, userId and the role; why a JWT cannot be revoked instantly and how it differs from a server-side session is covered in “Authentication: sessions, cookies and JWT”.
import "server-only";
import { SignJWT, jwtVerify } from "jose";
import { cookies } from "next/headers";
import type { Role } from "./users";
export type Session = { userId: string; role: Role };
export const SESSION_COOKIE = "session";
const MAX_AGE = 8 * 60 * 60; // 8 hours, in seconds
function secretKey(): Uint8Array {
const secret = process.env.SESSION_SECRET;
if (!secret || secret.length < 32) throw new Error("SESSION_SECRET must be at least 32 characters");
return new TextEncoder().encode(secret);
}
export async function encrypt(session: Session): Promise<string> {
return new SignJWT(session)
.setProtectedHeader({ alg: "HS256" })
.setIssuedAt()
.setExpirationTime(`${MAX_AGE}s`)
.sign(secretKey());
}
// null for a missing, forged or expired token
export async function decrypt(token: string | undefined): Promise<Session | null> {
if (!token) return null;
const key = secretKey();
try {
const { payload } = await jwtVerify<Session>(token, key, { algorithms: ["HS256"] });
return { userId: payload.userId, role: payload.role };
} catch {
return null;
}
}
export async function createSession(session: Session): Promise<void> {
(await cookies()).set(SESSION_COOKIE, await encrypt(session), {
httpOnly: true,
secure: true,
sameSite: "lax",
path: "/",
maxAge: MAX_AGE,
});
}
export async function readSession(): Promise<Session | null> {
return decrypt((await cookies()).get(SESSION_COOKIE)?.value);
}
export async function deleteSession(): Promise<void> {
(await cookies()).delete(SESSION_COOKIE);
}import "server-only";
import { scryptSync, timingSafeEqual } from "node:crypto";
export type Role = "admin" | "user";
type UserRecord = { id: string; email: string; name: string; role: Role; passwordHash: string };
// Demo store instead of a database. The password of all three accounts is demo-password.
const users: UserRecord[] = [
{ id: "u1", email: "anna@example.com", name: "Anna", role: "admin",
passwordHash: "c5d7d241f8ee010cd0a0f029a9067362:8759ad4bd2fa249549d79ac56d2575822aabd8e046490f8940f6d8764a8c711d" },
{ id: "u2", email: "boris@example.com", name: "Boris", role: "user",
passwordHash: "208dc01abee694e4ebc723a45b46c1e3:99ab502199edcc16938619f470775ee728707d9319fcb404a7ccc456e1d35f9f" },
// Vera used to be an admin; her role was changed to "user"
{ id: "u3", email: "vera@example.com", name: "Vera", role: "user",
passwordHash: "c45cff40548d478913c2193272ae1223:e865f5adddfc442710ac1fac69e1088f4aeef1d88addec338d8fbee40427082c" },
];
// Checked when the email is unknown, so the answer takes the same time
const DUMMY_HASH = "00000000000000000000000000000000:" + "0".repeat(64);
export function findUserById(id: string) {
return users.find((user) => user.id === id) ?? null;
}
export function verifyCredentials(email: string, password: string) {
const user = users.find((u) => u.email === email.trim().toLowerCase());
const [salt, hash] = (user?.passwordHash ?? DUMMY_HASH).split(":");
const matches = timingSafeEqual(scryptSync(password, salt, 32), Buffer.from(hash, "hex"));
return user && matches ? user : null;
}Proxy runs before the route renders. It checks only the cookie signature, with no database calls, because it runs on every request, including link prefetches. A guest gets a 307 to /login with the original path in next; a signed-in user without the admin role gets a 403. The matcher excludes static files: otherwise, as the docs warn, the check can block CSS and JavaScript from loading.
import { NextResponse, type NextRequest } from "next/server";
import { decrypt, SESSION_COOKIE } from "@/lib/session";
const under = (pathname: string, prefix: string) =>
pathname === prefix || pathname.startsWith(`${prefix}/`);
export async function proxy(request: NextRequest) {
const { pathname, search } = request.nextUrl;
if (!under(pathname, "/account") && !under(pathname, "/admin")) return NextResponse.next();
// Optimistic check: only the signed cookie, no database calls
const session = await decrypt(request.cookies.get(SESSION_COOKIE)?.value);
if (!session) {
const login = new URL("/login", request.url);
login.searchParams.set("next", pathname + search);
return NextResponse.redirect(login);
}
if (under(pathname, "/admin") && session.role !== "admin") {
return new NextResponse("403 Forbidden: this section is for admins only.", {
status: 403,
headers: { "content-type": "text/plain; charset=utf-8", "cache-control": "no-store" },
});
}
return NextResponse.next();
}
export const config = {
// Every route except static files: auth logic must not block CSS and JS
matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
};Server Actions are not separate routes but POST requests to the page that uses them. The docs warn that a matcher excluding a path also excludes the actions on it, and moving an action to another route silently removes proxy coverage. That is why the next layer is mandatory.
The Data Access Layer: checks next to the data
A Data Access Layer (DAL) is a module with import "server-only" that every data read and write goes through. The Next.js docs recommend keeping the session check there and returning only the fields the UI needs. React’s cache removes repeated checks within one render; it does nothing in Server Actions and Route Handlers, where every call runs the check again.
import "server-only";
import { cache } from "react";
import { forbidden, redirect } from "next/navigation";
import { readSession } from "./session";
import { findUserById, type Role } from "./users";
// Secure check: the cookie says who it is, the database says what they may do now
export const getCurrentUser = cache(async () => {
const session = await readSession();
if (!session) return null;
const user = findUserById(session.userId);
if (!user) return null;
return { id: user.id, name: user.name, role: user.role }; // only what the UI needs
});
export async function requireUser(returnTo: string) {
const user = await getCurrentUser();
if (!user) redirect(`/login?next=${encodeURIComponent(returnTo)}`);
return user;
}
export async function requireRole(role: Role, returnTo: string) {
const user = await requireUser(returnTo);
if (user.role !== role) forbidden();
return user;
}The key line is findUserById. Proxy trusts the role written into the cookie; the DAL reads the role from the database at request time. In the demo data Vera used to be an admin and is now a user. Her old cookie passes the proxy, but the DAL answers with forbidden(). The docs call this the difference between optimistic and secure checks.
import "server-only";
import { requireRole } from "./dal";
// Demo data instead of a database
const leads = [
{ id: "l1", name: "Aigerim", phone: "+77000000001", source: "contact-form" },
{ id: "l2", name: "Daniyar", phone: "+77000000002", source: "contact-form" },
];
// The role check sits next to the data: every caller goes through it
export async function listLeads() {
await requireRole("admin", "/admin");
return leads.map(({ id, name, phone }) => ({ id, name, phone }));
}
export async function removeLead(id: string) {
await requireRole("admin", "/admin");
const index = leads.findIndex((lead) => lead.id === id);
if (index !== -1) leads.splice(index, 1);
}forbidden() interrupts rendering and shows forbidden.tsx. The function is still experimental and is enabled with the authInterrupts flag; without it, you can call notFound() (the URL looks non-existent) or redirect() instead.
// next.config.ts
const nextConfig: NextConfig = {
// …your other options
experimental: {
authInterrupts: true, // enables forbidden() and forbidden.tsx
},
};import Link from "next/link";
export default function Forbidden() {
return (
<main className="p-8">
<h1 className="mb-4 text-2xl font-semibold">403 — No access</h1>
<p>Your role does not allow this page.</p>
<Link href="/account" className="underline">Back to your account</Link>
</main>
);
}forbidden() across the appPages, Server Actions and sign-in with a return path
With Cache Components, reading a cookie is request data, so the component that checks the session must sit inside <Suspense>, or the build stops with an error. The page’s static shell (the heading) is sent at once, and the lead list streams in after the check. A consequence described in the forbidden docs: if the check fires after streaming has started, the response keeps status 200, and Next.js shows forbidden.tsx and adds <meta name="robots" content="noindex">. The proper 307 and 403 HTTP codes before rendering come from the proxy.
import { Suspense } from "react";
import { listLeads } from "@/lib/leads";
import { deleteLead } from "./actions";
export default function AdminPage() {
return (
<main className="p-8">
<h1 className="mb-4 text-2xl font-semibold">Leads</h1>
<Suspense fallback={<p>Checking access…</p>}>
<LeadList />
</Suspense>
</main>
);
}
async function LeadList() {
const leads = await listLeads(); // redirect or 403 happen here
return (
<ul className="flex flex-col gap-2">
{leads.map((lead) => (
<li key={lead.id} className="flex gap-4">
{lead.name}, {lead.phone}
<form action={deleteLead.bind(null, lead.id)}>
<button type="submit" className="underline">Delete</button>
</form>
</li>
))}
</ul>
);
}"use server";
import { refresh } from "next/cache";
import { removeLead } from "@/lib/leads";
export async function deleteLead(id: string) {
// removeLead checks the session and the role itself:
// a direct POST that bypasses the page gets the same check
await removeLead(id);
refresh();
}removeLeadimport { Suspense } from "react";
import { requireUser } from "@/lib/dal";
import { logout } from "../login/actions";
export default function AccountPage() {
return (
<main className="p-8">
<h1 className="mb-4 text-2xl font-semibold">Account</h1>
<Suspense fallback={<p>Loading…</p>}>
<Profile />
</Suspense>
</main>
);
}
async function Profile() {
const user = await requireUser("/account");
return (
<>
<p>Signed in as {user.name} ({user.role})</p>
<form action={logout}>
<button type="submit" className="underline">Sign out</button>
</form>
</>
);
}Sign-in sends the person back where they came from, using the next parameter. This is a classic spot for an open redirect: OWASP advises against prefix string matching and recommends parsing the URL and accepting only your own destinations. safeNext parses next against a dummy origin and rejects anything that leads to another host.
"use server";
import { redirect } from "next/navigation";
import { createSession, deleteSession } from "@/lib/session";
import { verifyCredentials } from "@/lib/users";
export type LoginState = { error?: string };
// Only paths of this site: "/admin" yes; "https://evil.example", "//evil.example" no
function safeNext(value: FormDataEntryValue | null): string {
const fallback = "/account";
if (typeof value !== "string") return fallback;
try {
const url = new URL(value, "http://local");
if (url.origin !== "http://local" || url.pathname.startsWith("//")) return fallback;
return url.pathname + url.search;
} catch {
return fallback;
}
}
export async function login(_prev: LoginState, formData: FormData): Promise<LoginState> {
const email = formData.get("email");
const password = formData.get("password");
if (typeof email !== "string" || typeof password !== "string") {
return { error: "Wrong email or password." };
}
const user = verifyCredentials(email, password);
if (!user) return { error: "Wrong email or password." };
await createSession({ userId: user.id, role: user.role });
redirect(safeNext(formData.get("next")));
}
export async function logout() {
await deleteSession();
redirect("/login");
}/admin?tab=new
- What a browser would do
- Open a page of the site
safeNextresult/admin?tab=new
https://evil.example
- What a browser would do
- Leave for another site
safeNextresult/account
//evil.example
- What a browser would do
- Protocol-relative URL, another site
safeNextresult/account
/\evil.example
- What a browser would do
- The browser reads the backslash as
/, another site safeNextresult/account
/.//evil.example
- What a browser would do
- After normalisation the path is
//evil.example, another site safeNextresult/account
"use client";
import { useActionState } from "react";
import { login, type LoginState } from "./actions";
export function LoginForm({ next }: { next: string }) {
const [state, formAction, pending] = useActionState<LoginState, FormData>(login, {});
return (
<form action={formAction} className="flex max-w-sm flex-col gap-2">
<input type="hidden" name="next" value={next} />
<label htmlFor="email">Email</label>
<input id="email" name="email" type="email" required autoComplete="username" className="border p-2" />
<label htmlFor="password">Password</label>
<input id="password" name="password" type="password" required
autoComplete="current-password" className="border p-2" />
<button type="submit" disabled={pending} className="bg-black p-2 text-white disabled:opacity-50">
Sign in
</button>
<p role="status" className="text-red-700">{state.error}</p>
</form>
);
}import { Suspense } from "react";
import { LoginForm } from "./LoginForm";
type Props = { searchParams: Promise<{ next?: string | string[] }> };
export default function LoginPage({ searchParams }: Props) {
return (
<main className="p-8">
<h1 className="mb-4 text-2xl font-semibold">Sign in</h1>
<Suspense fallback={null}>
<LoginWithReturn searchParams={searchParams} />
</Suspense>
</main>
);
}
// searchParams is request data, so it is read inside the Suspense boundary
async function LoginWithReturn({ searchParams }: Props) {
const { next } = await searchParams;
return <LoginForm next={typeof next === "string" ? next : "/account"} />;
}searchParams is request data, so it is read inside SuspenseHow to plug it in
npm i jose server-only
npm i -D @playwright/test
openssl rand -base64 32 # the value for SESSION_SECRETSESSION_SECRET=YOUR_RANDOM_SECRET_AT_LEAST_32_CHARACTERSCopy the files
lib/session.ts,lib/dal.ts,proxy.tsin the project root (next toapp/),app/forbidden.tsxand theauthInterruptsflag.Connect your database
Replace
lib/users.tswith queries to your users table. Therolefield must be read from the database on every DAL check; otherwise a role change only takes effect when the cookie expires.Name the protected sections
Add the prefixes to
proxy.tsand callrequireUserorrequireRolein every data function of those sections, not in the layout.A rule for the team
Every new read or write function lives in
lib/and starts with a DAL check. Server Actions call only such functions, so a check forgotten in an action does not become a hole.
How to verify it
A quick check is curl against next start: a guest gets a 307 with the return path, a user with the user role gets a 403. USER_TOKEN is a user-role token signed with the same test secret.
$ curl -sI http://localhost:3000/admin
HTTP/1.1 307 Temporary Redirect
location: /login?next=%2Fadmin
$ curl -si -H "Cookie: session=$USER_TOKEN" http://localhost:3000/admin
HTTP/1.1 403 Forbidden
cache-control: no-store
content-type: text/plain; charset=utf-8
403 Forbidden: this section is for admins only.The full check is Playwright against a production build. The stale-session test signs an admin token for Vera with the same test secret, reproducing a cookie issued before her role changed.
import { defineConfig, devices } from "@playwright/test";
// Test-only secret; the tests use it to forge a stale session
process.env.SESSION_SECRET ??= "test-only-secret-at-least-32-characters";
export default defineConfig({
testDir: "./e2e",
reporter: "list",
use: { baseURL: "http://localhost:3270", ...devices["Desktop Chrome"] },
webServer: {
command: "npm run start -- -p 3270",
url: "http://localhost:3270",
env: { SESSION_SECRET: process.env.SESSION_SECRET },
},
});import { expect, test, type Page } from "@playwright/test";
import { SignJWT } from "jose";
async function signIn(page: Page, email: string, path = "/login") {
await page.goto(path);
await page.getByLabel("Email").fill(email);
await page.getByLabel("Password").fill("demo-password");
await page.getByRole("button", { name: "Sign in" }).click();
}
// A token issued while Vera was still an admin; her role in the "database" is now user
async function staleAdminToken() {
const key = new TextEncoder().encode(process.env.SESSION_SECRET);
return new SignJWT({ userId: "u3", role: "admin" })
.setProtectedHeader({ alg: "HS256" })
.setIssuedAt()
.setExpirationTime("1h")
.sign(key);
}
test("guest: proxy redirects to /login and keeps the path", async ({ request }) => {
const res = await request.get("/admin?tab=new", { maxRedirects: 0 });
expect(res.status()).toBe(307);
expect(res.headers()["location"]).toBe("/login?next=%2Fadmin%3Ftab%3Dnew");
});
test("forged cookie is treated as no session", async ({ request }) => {
const res = await request.get("/account", {
maxRedirects: 0,
headers: { cookie: "session=eyJhbGciOiJIUzI1NiJ9.eyJ1c2VySWQiOiJ1MSIsInJvbGUiOiJhZG1pbiJ9.forged" },
});
expect(res.status()).toBe(307);
});
test("admin signs in and returns to the requested page", async ({ page }) => {
await page.goto("/admin");
await expect(page).toHaveURL("/login?next=%2Fadmin");
await signIn(page, "anna@example.com", page.url());
await expect(page).toHaveURL("/admin");
await expect(page.getByText("Aigerim, +77000000001")).toBeVisible();
});
test("user role: /account opens, /admin answers 403", async ({ page }) => {
await signIn(page, "boris@example.com");
await expect(page).toHaveURL("/account");
await expect(page.getByText("Signed in as Boris (user)")).toBeVisible();
const res = await page.goto("/admin");
expect(res?.status()).toBe(403);
await expect(page.locator("body")).toContainText("403 Forbidden");
await expect(page.locator("body")).not.toContainText("+77000000001");
});
test("stale token passes proxy, but the DAL refuses", async ({ page, context }) => {
await context.addCookies([{ name: "session", value: await staleAdminToken(),
domain: "localhost", path: "/", httpOnly: true, secure: true, sameSite: "Lax" }]);
await page.goto("/admin");
await expect(page.getByRole("heading", { name: "403 — No access" })).toBeVisible();
await expect(page.locator('head meta[name="robots"]')).toHaveAttribute("content", "noindex");
expect(await page.content()).not.toContain("+77000000001");
});
test("server action re-checks the role", async ({ page, context }) => {
await signIn(page, "anna@example.com", "/login?next=/admin");
await expect(page.getByText("Aigerim, +77000000001")).toBeVisible();
const annaCookies = await context.cookies();
// The page is open, but the session now belongs to someone without the role
await context.clearCookies();
await context.addCookies([{ name: "session", value: await staleAdminToken(),
domain: "localhost", path: "/", httpOnly: true, secure: true, sameSite: "Lax" }]);
await page.locator("li", { hasText: "Aigerim" }).getByRole("button", { name: "Delete" }).click();
await expect(page.getByRole("heading", { name: "403 — No access" })).toBeVisible();
await context.clearCookies();
await context.addCookies(annaCookies);
await page.goto("/admin");
await expect(page.getByText("Aigerim, +77000000001")).toBeVisible();
});
test("return path cannot lead to another site", async ({ page }) => {
for (const next of ["https://evil.example", "//evil.example", "/\\evil.example", "/.//evil.example"]) {
await page.context().clearCookies();
await signIn(page, "anna@example.com", `/login?next=${encodeURIComponent(next)}`);
await expect(page).toHaveURL("/account");
}
});
test("sign out closes the account again", async ({ page }) => {
await signIn(page, "boris@example.com");
await page.getByRole("button", { name: "Sign out" }).click();
await expect(page).toHaveURL("/login");
await page.goto("/account");
await expect(page).toHaveURL("/login?next=%2Faccount");
});
test("admin deletes a lead", async ({ page }) => {
await signIn(page, "anna@example.com", "/login?next=/admin");
await page.locator("li", { hasText: "Daniyar" }).getByRole("button", { name: "Delete" }).click();
await expect(page.getByText("Daniyar")).toHaveCount(0);
await expect(page.getByText("Aigerim, +77000000001")).toBeVisible();
});$ npm run build
…
Route (app)
┌ ○ /
├ ○ /_not-found
├ ◐ /account
├ ◐ /admin
└ ◐ /login
ƒ Proxy (Middleware)
○ (Static) prerendered as static content
◐ (Partial Prerender) prerendered as static HTML with dynamic server-streamed content
$ npx playwright test
Running 9 tests using 1 worker
ok 1 e2e\access.spec.ts:21:5 › guest: proxy redirects to /login and keeps the path (27ms)
ok 2 e2e\access.spec.ts:27:5 › forged cookie is treated as no session (11ms)
ok 3 e2e\access.spec.ts:35:5 › admin signs in and returns to the requested page (2.1s)
ok 4 e2e\access.spec.ts:44:5 › user role: /account opens, /admin answers 403 (1.4s)
ok 5 e2e\access.spec.ts:55:5 › stale token passes proxy, but the DAL refuses (322ms)
ok 6 e2e\access.spec.ts:65:5 › server action re-checks the role (1.7s)
ok 7 e2e\access.spec.ts:83:5 › return path cannot lead to another site (2.7s)
ok 8 e2e\access.spec.ts:91:5 › sign out closes the account again (1.5s)
ok 9 e2e\access.spec.ts:100:5 › admin deletes a lead (1.3s)
9 passed (15.7s)We checked the tests the other way round. Without the forbidden() line in requireRole, the stale-session and Server Action tests failed: Vera deleted a lead with her old cookie. Without the pathname.startsWith("//") check, /.//evil.example sent the browser to another domain. Without proxy.ts, only the status code checks failed: the guest got a 200 with <meta http-equiv="refresh" content="1;url=/login?next=%2Fadmin">, the user got a 200 with the 403 page, and in neither case did the HTML contain the lead phone numbers. The DAL protects the data; the proxy provides the right HTTP codes and saves rendering for requests that will be refused anyway.
Limitations and next step
- A JWT in a cookie cannot be revoked before it expires (8 hours here): signing out removes the cookie in one browser only. For instant revocation, store sessions in a database, as in the Database Sessions section of the docs
- Proxy trusts the role in the cookie: someone whose role was revoked gets the static shell of the admin page, but not the data
forbidden()andauthInterruptsare experimental APIs; check their status when you upgrade Next.js- There is no sign-in rate limit or brute-force lockout; add them following the authentication lesson
- Data loaded at build time and shared by everyone is not protected by the DAL: the docs advise protecting such static routes in proxy
- Checking whether a record is yours (IDOR) is a separate layer: the
adminrole allows the action, not any record in a multi-tenant project
The role matrix and per-record access checks are covered in “Authorization: roles, permissions and server-side access checks”. Signing in with Google or a corporate account is in “OAuth 2.0 and OpenID Connect”: the sign-in method changes, while proxy.ts and the DAL from this recipe stay the same. The form that brings leads into this admin area is the recipe “A lead form on a Server Action with validation”.
How this was verified5 · passedchecked 12 October 2026
“Run” means the code was executed in a scratch project or container and the output in the article comes from that run. “Partial” means it was checked against stubs instead of external services. “Not run” means it needs a device, an account or a key; the steps were checked against the official documentation. The verification project files and run logs are kept in the InterLAB archive.