В Next.js 16 закрытый раздел защищают в два слоя. proxy.ts (так с 16-й версии называется middleware) быстро проверяет подпись cookie сессии: гостя отправляет на /login?next=… с кодом 307, пользователю без нужной роли отвечает 403. Настоящая защита — слой доступа к данным (Data Access Layer): каждая функция, которая читает или меняет данные, сама проверяет сессию и актуальную роль по базе, поэтому её не обходят ни прямой запрос, ни устаревшая cookie, ни Server Action, вызванный без страницы.
После материала вы сможете
- закрыть раздел оптимистичной проверкой в
proxy.ts: редирект гостя на вход с возвратом и 403 для чужой роли - вынести проверку сессии и роли в слой доступа к данным (
requireUser,requireRole) и опираться на него в страницах и Server Actions - вернуть пользователя после входа на исходную страницу без уязвимости открытого редиректа
- проверить защиту Playwright-тестами: гость, вошедший, чужая роль, устаревшая сессия
Что знать заранее
Проверено на
Next.js 16.4 (Cache Components)React 19.3jose 6.2TypeScript 5.9Node.js 24 LTS (24.18)Playwright 1.64
Сверено с документацией: 12 октября 2026 г.
Содержание
Задача и модель угроз
Задача: кабинет /account открыт любому вошедшему пользователю, раздел заявок /admin — только роли admin. Спрятать ссылку «Админка» в меню недостаточно: защищать нужно сами ответы сервера. Вот что пробует тот, кому доступ не положен:
- открывает
/adminнапрямую, без cookie или с поддельной cookie; - входит со старой cookie, выданной, когда у него ещё была роль администратора;
- отправляет POST в Server Action удаления заявки, минуя страницу;
- подсовывает в ссылку входа
?next=https://evil.example, чтобы после входа увести человека на свой сайт.
proxy.ts
- Что видит проверка
- Подпись и срок cookie, роль, записанную в cookie; базу не трогает
- Достаточно ли её
- Нет. Документация называет её оптимистичной и не советует делать единственной защитой
Макет (layout.tsx)
- Что видит проверка
- То же, что страница
- Достаточно ли её
- Нет. Макет не перерисовывается при клиентской навигации и не управляет тем, рендерятся ли дочерние сегменты
Слой доступа к данным
- Что видит проверка
- Сессию и актуальную роль из базы — рядом с самими данными
- Достаточно ли её
- Да, это основной рубеж
Server Action
- Что видит проверка
- То же через слой доступа к данным
- Достаточно ли её
- Обязательна: действие — публичный POST-запрос
Сессия и оптимистичная проверка в proxy.ts
Сессия — подписанный JWT в cookie с флагом HttpOnly, как в разделе Stateless Sessions документации Next.js: библиотека jose, алгоритм HS256, секрет из переменной окружения. В токене только минимум — userId и роль; почему JWT нельзя мгновенно отозвать и чем он отличается от серверной сессии, разобрано в уроке «Аутентификация: сессии, cookies и 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 выполняется до рендера маршрута. Он проверяет только подпись cookie — без обращений к базе, потому что работает на каждом запросе, включая предзагрузку (prefetch) ссылок. Гость получает 307 на /login с исходным путём в next, вошедший без роли admin — 403. matcher исключает статические файлы: иначе, как предупреждает документация, проверка может заблокировать загрузку CSS и JavaScript.
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 не отдельные маршруты, а POST на страницу, где они используются. Документация предупреждает: matcher, исключающий путь, исключает и действия на нём, а перенос действия на другой маршрут молча снимает защиту proxy. Поэтому следующий слой обязателен.
Слой доступа к данным: проверка рядом с данными
Data Access Layer (DAL) — модуль с import "server-only", через который проходят все чтения и изменения данных. Документация Next.js рекомендует держать в нём функцию проверки сессии и возвращать из неё только нужные интерфейсу поля. cache из React убирает повторные проверки в пределах одного рендера; в Server Actions и Route Handlers он не работает, и каждый вызов выполняет проверку заново.
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;
}Ключевая строка — findUserById. Proxy верит роли, записанной в cookie, а DAL берёт роль из базы на момент запроса. В демо-данных у Веры раньше была роль admin, теперь — user. Её старая cookie проходит proxy, но DAL отвечает forbidden(). В документации это различие называется optimistic и 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() прерывает рендер и показывает forbidden.tsx. Функция пока экспериментальная и включается флагом authInterrupts; без флага вместо неё можно вызвать notFound() (адрес выглядит несуществующим) или redirect().
// 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() во всём приложенииСтраницы, Server Actions и вход с возвратом
С Cache Components чтение cookie — данные запроса, поэтому компонент, который проверяет сессию, должен стоять внутри <Suspense>: иначе сборка остановится с ошибкой. Статическая оболочка страницы (заголовок) отдаётся сразу, а список заявок — потоком после проверки. Следствие из документации forbidden: если проверка сработала после начала потоковой передачи, ответ остаётся с кодом 200 — Next.js показывает forbidden.tsx и добавляет <meta name="robots" content="noindex">. Правильные HTTP-коды 307 и 403 до рендера даёт 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>
</>
);
}Вход возвращает человека туда, откуда его отправили, — по параметру next. Это классическая точка для открытого редиректа (open redirect): OWASP советует не сравнивать строки по префиксу, а разбирать адрес парсером и принимать только свои назначения. Функция safeNext разбирает next относительно фиктивного origin и отвергает всё, что ведёт на другой хост.
"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
- Что сделал бы браузер
- Откроет страницу сайта
- Результат
safeNext /admin?tab=new
https://evil.example
- Что сделал бы браузер
- Уйдёт на чужой сайт
- Результат
safeNext /account
//evil.example
- Что сделал бы браузер
- Протокол-относительный адрес — чужой сайт
- Результат
safeNext /account
/\evil.example
- Что сделал бы браузер
- Обратную косую браузер читает как
/— чужой сайт - Результат
safeNext /account
/.//evil.example
- Что сделал бы браузер
- После нормализации путь
//evil.example— чужой сайт - Результат
safeNext /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 — данные запроса, поэтому читаются внутри SuspenseКак подключить
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_CHARACTERSСкопируйте файлы
lib/session.ts,lib/dal.ts,proxy.tsв корень проекта (рядом сapp/),app/forbidden.tsxи флагauthInterrupts.Подключите свою базу
Замените
lib/users.tsзапросами к своей таблице пользователей. Полеroleдолжно читаться из базы при каждой проверке в DAL — иначе смена роли подействует только после истечения cookie.Назовите закрытые разделы
Добавьте префиксы в
proxy.tsи вызывайтеrequireUserилиrequireRoleв каждой функции данных этих разделов, а не в макете.Правило для команды
Любая новая функция чтения или записи живёт в
lib/и начинается с проверки из DAL. Server Action вызывает только такие функции — тогда забытая проверка в действии не становится дырой.
Как проверить
Быстрая проверка — curl против next start: гость получает 307 с адресом возврата, пользователь с ролью user — 403. USER_TOKEN — токен роли user, подписанный тем же тестовым секретом.
$ 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.Полная проверка — Playwright против production-сборки. Тест «устаревшей сессии» подписывает токен с ролью admin для Веры тем же тестовым секретом: так воспроизводится cookie, выданная до смены роли.
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)Тесты мы проверили от обратного. Без строки forbidden() в requireRole упали тесты устаревшей сессии и Server Action: Вера со старой cookie удалила заявку. Без проверки pathname.startsWith("//") адрес /.//evil.example увёл браузер на чужой домен. Без proxy.ts упали только проверки кодов: гость получил 200 и <meta http-equiv="refresh" content="1;url=/login?next=%2Fadmin">, пользователь — 200 со страницей 403, но телефонов из заявок не было в HTML ни в одном случае. Данные защищает DAL, а proxy даёт правильные HTTP-коды и не тратит рендер на тех, кого всё равно не пустят.
Ограничения и следующий шаг
- JWT в cookie нельзя отозвать до истечения срока (здесь 8 часов): выход удаляет cookie только в одном браузере. Нужен мгновенный отзыв — храните сессии в базе, как в разделе Database Sessions документации
- Proxy верит роли из cookie: человек с отозванной ролью получит статическую оболочку админки, но не данные
forbidden()иauthInterrupts— экспериментальные API; при обновлении Next.js проверяйте их статус- Нет лимита попыток входа и блокировки перебора паролей — их добавляют по рекомендациям из урока об аутентификации
- Данные, которые загружаются при сборке и одинаковы для всех, DAL не защитит: документация советует закрывать такие статические маршруты в proxy
- Проверка «своя ли это запись» (IDOR) — отдельный слой: роль
adminразрешает действие, но не любую запись в мультиарендном проекте
Матрицу ролей и проверку доступа к конкретной записи подробно разбирает урок «Авторизация: роли, права и проверка доступа на сервере». Вход через Google или корпоративный аккаунт — «OAuth 2.0 и OpenID Connect»: меняется способ входа, а proxy.ts и DAL из этого рецепта остаются прежними. Форма, через которую в админку попадают заявки, — в рецепте «Форма заявки на Server Action с валидацией».
Как проверено5 · пройденосверено 12 октября 2026 г.
- запущено, работает
lib/session.ts, lib/users.ts, lib/dal.ts, lib/leads.ts, proxy.ts, app/forbidden.tsx, app/admin/*, app/account/page.tsx, app/login/*
create-next-app@16.4.0 --yes (Cache Components, React 19.3.0, TS 5.9.3) + jose 6.2.12 + server-only, experimental.authInterrupts: tsc --noEmit OK, eslint OK, next build OK (/account, /admin, /login ◐, ƒ Proxy); код статьи считан генератором из файлов проекта; next.config.ts в статье — фрагмент
- запущено, работает
playwright.config.ts, e2e/access.spec.ts
@playwright/test 1.64.0 (Chromium) против next start: 9/9 passed, итоговая версия прогнана 2 раза подряд; вывод в статье — реальный
- запущено, работает
curl: гость → 307, роль user → 403
curl против next start (порт в статье заменён на 3000, заголовки Date/Connection опущены); токен роли user подписан тестовым секретом через jose
- запущено, работает
Проверка от обратного
без forbidden() в requireRole упали тесты устаревшей сессии и Server Action (заявка удалена); без проверки pathname.startsWith('//') браузер ушёл на evil.example по /.//evil.example; без proxy.ts — гость 200 с meta refresh на /login?next=%2Fadmin, user — 200 со страницей 403 и noindex, телефонов заявок в HTML нет; код восстановлен
- запущено, работает
Rewrite на статическую страницу со status 403 (отвергнутый вариант)
NextResponse.rewrite(new URL('/no-access'), { status: 403 }) на статическую страницу: curl получил 200, x-middleware-rewrite: /no-access, Cache-Control: s-maxage=31536000; заменено прямым ответом 403
«Запущено» — код выполнен во временном проекте или контейнере, вывод в статье взят из прогона. «Частично» — проверено с заглушками вместо внешних сервисов. «Не запускалось» — нужны устройство, аккаунт или ключ; шаги сверены с официальной документацией. Файлы проверочного проекта и логи запусков сохранены в архиве InterLAB.