How to buildOctober 12, 20268 min read
Next.jsStep 7 of 7

Recipe: protected pages in Next.js

In short

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.

Code recipeLevel: IntermediateNext.js SolutionsPractice: ~40–90 min

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

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
  1. 01The task and the threat model
  2. 02The session and the optimistic check in proxy.ts
  3. 03The Data Access Layer: checks next to the data
  4. 04Pages, Server Actions and sign-in with a return path
  5. 05How to plug it in
  6. 06How to verify it
  7. 07Limitations and next step

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 /admin directly, 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.example into 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”.

lib/session.ts
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);
}
Signing, verifying and the session cookie; the module refuses to work without a secret of 32+ characters
lib/users.ts
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;
}
A demo store instead of a database: scrypt hashes and a constant-time comparison

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.

proxy.ts
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).*)"],
};
The optimistic check: redirect a guest with a return path, 403 for the wrong role

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.

lib/dal.ts
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;
}
Session and role checks: who comes from the cookie, what they may do comes from the database

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.

lib/leads.ts
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);
}
Data is reachable only through functions that check the role

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
// next.config.ts
const nextConfig: NextConfig = {
  // …your other options
  experimental: {
    authInterrupts: true, // enables forbidden() and forbidden.tsx
  },
};
Fragment: add the flag to your own settings, not a standalone file
app/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>
  );
}
The UI for forbidden() across the app

Pages, 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.

app/admin/page.tsx
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>
  );
}
The page shell is static; the data and the role check stream inside Suspense
app/admin/actions.ts
"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();
}
The action does not trust the page: the check lives inside removeLead
app/account/page.tsx
import { 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>
    </>
  );
}
The dashboard: any signed-in user

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.

app/login/actions.ts
"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");
}
Sign-in, sign-out and a safe return path

/admin?tab=new

What a browser would do
Open a page of the site
safeNext result
/admin?tab=new

https://evil.example

What a browser would do
Leave for another site
safeNext result
/account

//evil.example

What a browser would do
Protocol-relative URL, another site
safeNext result
/account

/\evil.example

What a browser would do
The browser reads the backslash as /, another site
safeNext result
/account

/.//evil.example

What a browser would do
After normalisation the path is //evil.example, another site
safeNext result
/account
app/login/LoginForm.tsx
"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>
  );
}
The sign-in form: the return path in a hidden field
app/login/page.tsx
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 Suspense

How to plug it in

Terminal
npm i jose server-only
npm i -D @playwright/test
openssl rand -base64 32   # the value for SESSION_SECRET
Dependencies and the secret (the openssl command comes from the Next.js docs)
.env.local
SESSION_SECRET=YOUR_RANDOM_SECRET_AT_LEAST_32_CHARACTERS
A placeholder: put in your own random secret and do not commit the file
  1. Copy the files

    lib/session.ts, lib/dal.ts, proxy.ts in the project root (next to app/), app/forbidden.tsx and the authInterrupts flag.

  2. Connect your database

    Replace lib/users.ts with queries to your users table. The role field must be read from the database on every DAL check; otherwise a role change only takes effect when the cookie expires.

  3. Name the protected sections

    Add the prefixes to proxy.ts and call requireUser or requireRole in every data function of those sections, not in the layout.

  4. 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.

Terminal
$ 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.
Real output; the Date and Connection headers are omitted and the port is replaced with the default 3000

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.

playwright.config.ts
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 },
  },
});
The test secret is visible to both the server and the tests
e2e/access.spec.ts
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();
});
Nine scenarios: guest, forged cookie, sign-in with return, wrong role, stale session, Server Action, open redirect, sign-out, admin delete
Terminal
$ 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)
Real build and test output (Windows, Playwright’s Chromium)

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() and authInterrupts are 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 admin role 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.

Tell us about your task — we'll review your request and get back to you

Got a project?
We'll scope and estimate it — for free.

Tell us about the product or the problem. After reviewing the task we'll come back with an architecture, a timeline and a price for the first version.

  • Scope and risks estimated before we start
  • NDA — yours or ours
  • Work starts within a week

Please do not send passwords, access keys or source code through this form — we will agree a safe way to share access separately.

By clicking the button you agree to the processing of your personal data.