Как делать12 октября 2026 г.6 мин чтения
Next.jsШаг 7 из 7

Рецепт: закрытые страницы в Next.js

Коротко

В Next.js 16 закрытый раздел защищают в два слоя. proxy.ts (так с 16-й версии называется middleware) быстро проверяет подпись cookie сессии: гостя отправляет на /login?next=… с кодом 307, пользователю без нужной роли отвечает 403. Настоящая защита — слой доступа к данным (Data Access Layer): каждая функция, которая читает или меняет данные, сама проверяет сессию и актуальную роль по базе, поэтому её не обходят ни прямой запрос, ни устаревшая cookie, ни Server Action, вызванный без страницы.

Рецепт кодаУровень: СреднийРешения для Next.jsПрактика: ~40–90 мин

После материала вы сможете

  • закрыть раздел оптимистичной проверкой в 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 г.

Содержание
  1. 01Задача и модель угроз
  2. 02Сессия и оптимистичная проверка в proxy.ts
  3. 03Слой доступа к данным: проверка рядом с данными
  4. 04Страницы, Server Actions и вход с возвратом
  5. 05Как подключить
  6. 06Как проверить
  7. 07Ограничения и следующий шаг

Задача и модель угроз

Задача: кабинет /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».

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);
}
Подпись, проверка и cookie сессии; без секрета длиной от 32 символов модуль не работает
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;
}
Демо-хранилище вместо базы: хеши scrypt и сравнение за постоянное время

Proxy выполняется до рендера маршрута. Он проверяет только подпись cookie — без обращений к базе, потому что работает на каждом запросе, включая предзагрузку (prefetch) ссылок. Гость получает 307 на /login с исходным путём в next, вошедший без роли admin — 403. matcher исключает статические файлы: иначе, как предупреждает документация, проверка может заблокировать загрузку CSS и JavaScript.

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).*)"],
};
Оптимистичная проверка: редирект гостя с возвратом и 403 для чужой роли

Server Actions не отдельные маршруты, а POST на страницу, где они используются. Документация предупреждает: matcher, исключающий путь, исключает и действия на нём, а перенос действия на другой маршрут молча снимает защиту proxy. Поэтому следующий слой обязателен.

Слой доступа к данным: проверка рядом с данными

Data Access Layer (DAL) — модуль с import "server-only", через который проходят все чтения и изменения данных. Документация Next.js рекомендует держать в нём функцию проверки сессии и возвращать из неё только нужные интерфейсу поля. cache из React убирает повторные проверки в пределах одного рендера; в Server Actions и Route Handlers он не работает, и каждый вызов выполняет проверку заново.

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;
}
Проверка сессии и роли: кто — из cookie, что можно — из базы

Ключевая строка — findUserById. Proxy верит роли, записанной в cookie, а DAL берёт роль из базы на момент запроса. В демо-данных у Веры раньше была роль admin, теперь — user. Её старая cookie проходит proxy, но DAL отвечает forbidden(). В документации это различие называется optimistic и 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);
}
Данные доступны только через функции с проверкой роли

forbidden() прерывает рендер и показывает forbidden.tsx. Функция пока экспериментальная и включается флагом authInterrupts; без флага вместо неё можно вызвать notFound() (адрес выглядит несуществующим) или redirect().

next.config.ts
// next.config.ts
const nextConfig: NextConfig = {
  // …your other options
  experimental: {
    authInterrupts: true, // enables forbidden() and forbidden.tsx
  },
};
Фрагмент: флаг добавляется к вашим настройкам, не самостоятельный файл
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>
  );
}
Интерфейс для forbidden() во всём приложении

Страницы, Server Actions и вход с возвратом

С Cache Components чтение cookie — данные запроса, поэтому компонент, который проверяет сессию, должен стоять внутри <Suspense>: иначе сборка остановится с ошибкой. Статическая оболочка страницы (заголовок) отдаётся сразу, а список заявок — потоком после проверки. Следствие из документации forbidden: если проверка сработала после начала потоковой передачи, ответ остаётся с кодом 200 — Next.js показывает forbidden.tsx и добавляет <meta name="robots" content="noindex">. Правильные HTTP-коды 307 и 403 до рендера даёт 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>
  );
}
Оболочка страницы статична, данные и проверка роли — внутри 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();
}
Действие не доверяет странице: проверка внутри 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>
    </>
  );
}
Кабинет: любой вошедший пользователь

Вход возвращает человека туда, откуда его отправили, — по параметру next. Это классическая точка для открытого редиректа (open redirect): OWASP советует не сравнивать строки по префиксу, а разбирать адрес парсером и принимать только свои назначения. Функция safeNext разбирает next относительно фиктивного origin и отвергает всё, что ведёт на другой хост.

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");
}
Вход, выход и безопасный адрес возврата

/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
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>
  );
}
Форма входа: адрес возврата в скрытом поле
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 — данные запроса, поэтому читаются внутри Suspense

Как подключить

Terminal
npm i jose server-only
npm i -D @playwright/test
openssl rand -base64 32   # the value for SESSION_SECRET
Зависимости и генерация секрета (команда openssl — из документации Next.js)
.env.local
SESSION_SECRET=YOUR_RANDOM_SECRET_AT_LEAST_32_CHARACTERS
Плейсхолдер: подставьте свой случайный секрет и не коммитьте файл
  1. Скопируйте файлы

    lib/session.ts, lib/dal.ts, proxy.ts в корень проекта (рядом с app/), app/forbidden.tsx и флаг authInterrupts.

  2. Подключите свою базу

    Замените lib/users.ts запросами к своей таблице пользователей. Поле role должно читаться из базы при каждой проверке в DAL — иначе смена роли подействует только после истечения cookie.

  3. Назовите закрытые разделы

    Добавьте префиксы в proxy.ts и вызывайте requireUser или requireRole в каждой функции данных этих разделов, а не в макете.

  4. Правило для команды

    Любая новая функция чтения или записи живёт в lib/ и начинается с проверки из DAL. Server Action вызывает только такие функции — тогда забытая проверка в действии не становится дырой.

Как проверить

Быстрая проверка — curl против next start: гость получает 307 с адресом возврата, пользователь с ролью user — 403. USER_TOKEN — токен роли user, подписанный тем же тестовым секретом.

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.
Реальный вывод; служебные заголовки Date и Connection опущены, порт заменён на стандартный 3000

Полная проверка — Playwright против production-сборки. Тест «устаревшей сессии» подписывает токен с ролью admin для Веры тем же тестовым секретом: так воспроизводится cookie, выданная до смены роли.

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 },
  },
});
Тестовый секрет виден и серверу, и тестам
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();
});
Девять сценариев: гость, поддельная cookie, вход с возвратом, чужая роль, устаревшая сессия, Server Action, открытый редирект, выход, удаление админом
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)
Реальный вывод сборки и тестов (Windows, Chromium из Playwright)

Тесты мы проверили от обратного. Без строки 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.

Расскажите о задаче — мы изучим запрос и свяжемся с вами

Есть задача?
Обсудим и оценим — бесплатно.

Расскажите о продукте или проблеме. Предложим архитектуру, срок и стоимость первой версии после изучения задачи.

  • Оценка объёма и рисков до старта
  • NDA — по вашему или нашему шаблону
  • Старт работ в течение недели

Не присылайте через форму пароли, ключи доступа и исходный код — безопасную передачу доступа обсудим отдельно.

Нажимая кнопку, вы соглашаетесь с обработкой персональных данных.