Пошук уроків, статей та іншого контенту
Знайдете й виправите типові помилки з cookies, сесіями, редиректами, кешуванням і перевіркою прав у Next.js.
У Next.js автентифікація складається з кількох незалежних етапів:
Користувач надсилає облікові дані.
Сервер перевіряє їх і створює сесію.
Сесія зберігається в cookie або на клієнті.
Сервер читає сесію для кожного захищеного запиту.
Сервер перевіряє не лише особу, а й права доступу.
Результат не повинен випадково потрапити в кеш або бути використаний іншим користувачем.
Типова помилка — вважати, що наявність cookie означає наявність прав. Cookie лише допомагає ідентифікувати сесію. Авторизацію потрібно перевіряти окремо.
Сесійна cookie зазвичай має такі атрибути:
httpOnly: true — JavaScript у браузері не може прочитати cookie;
secure: true — cookie передається лише через HTTPS;
sameSite: "lax" або суворіше значення — зменшує ризик CSRF;
path: "/" — cookie доступна для всього застосунку;
maxAge або expires — обмежує час життя cookie.
import { cookies } from "next/headers";
export async function createSessionCookie(sessionId) {
const cookieStore = await cookies();
cookieStore.set("session", sessionId, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
path: "/",
maxAge: 60 * 60 * 24 * 7,
});
}Поширені проблеми:
cookie не має httpOnly, тому її може прочитати XSS-код;
у production використовується secure: false;
вказано неправильний path, тому cookie не надсилається на потрібний маршрут;
cookie створюється без обмеженого часу життя;
під час logout видаляється cookie з іншими атрибутами path або domain.
Для видалення cookie потрібно використовувати ті самі параметри області дії:
import { cookies } from "next/headers";
export async function deleteSessionCookie() {
const cookieStore = await cookies();
cookieStore.set("session", "", {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
path: "/",
maxAge: 0,
});
}Секретну сесійну cookie не потрібно читати в Client Component. Якщо компонент отримує sessionId або JWT через props, атрибут httpOnly втрачає значну частину сенсу.
Неправильний підхід:
"use client";
document.cookie;Правильний підхід:
прочитати cookie на сервері;
перевірити сесію на сервері;
передати клієнту лише безпечні дані, наприклад ім’я та роль.
Не передавайте в браузер:
хеш пароля;
внутрішній ідентифікатор сесії;
секретні claims;
токени сторонніх сервісів;
повний об’єкт користувача з приватними полями.
cookiesУ сучасному App Router API cookies() використовується асинхронно:
import { cookies } from "next/headers";
export async function readSessionId() {
const cookieStore = await cookies();
return cookieStore.get("session")?.value ?? null;
}Синхронний варіант може працювати в режимі сумісності в окремих версіях, але не варто будувати на цьому новий код.
Cookie має обмежений розмір і надсилається з кожним HTTP-запитом. Не зберігайте в ній великий об’єкт користувача.
Практичні варіанти:
cookie містить випадковий ідентифікатор, а дані сесії зберігаються на сервері;
cookie містить підписаний токен з мінімальним набором claims.
Для серверної сесії бажано зберігати:
випадковий ідентифікатор сесії;
ідентифікатор користувача;
час створення;
час завершення;
час останнього використання;
за потреби — інформацію про відкликання.
Сам ідентифікатор має бути криптографічно випадковим. Не використовуйте:
email;
userId без додаткового захисту;
timestamp;
послідовний integer;
MD5 або SHA-1 від передбачуваного значення.
Не можна вважати сесію дійсною лише тому, що cookie існує. Потрібно перевірити:
чи знайдена сесія в сховищі;
чи не завершився її термін;
чи не відкликана вона;
чи активний пов’язаний користувач.
Під час кожного звернення до захищеної частини застосунку сервер має отримувати актуальний стан сесії.
Logout повинен:
відкликати або видалити сесію на сервері;
видалити cookie у браузері;
не покладатися лише на перенаправлення на іншу сторінку.
Якщо видалити тільки cookie, викрадена сесія може залишатися дійсною в іншому браузері.
Після успішного входу не варто продовжувати використовувати ідентифікатор сесії, створений до автентифікації. Інакше виникає ризик session fixation.
Безпечніший порядок:
видалити або деактивувати стару анонімну сесію;
створити новий ідентифікатор;
прив’язати його до користувача;
записати новий ідентифікатор у cookie.
Перевірку сесії краще винести в одну серверну функцію. Це зменшує ризик, що різні сторінки реалізують перевірку по-різному.
Приклад із серверним сховищем сесій:
// lib/auth.js
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import { getSessionById } from "./session-store";
export async function getCurrentUser() {
const cookieStore = await cookies();
const sessionId = cookieStore.get("session")?.value;
if (!sessionId) {
return null;
}
const session = await getSessionById(sessionId);
if (!session) {
return null;
}
if (session.expiresAt <= new Date()) {
return null;
}
return session.user;
}
export async function requireUser() {
const user = await getCurrentUser();
if (!user) {
redirect("/login");
}
return user;
}
export async function requireRole(role) {
const user = await requireUser();
if (user.role !== role) {
redirect("/forbidden");
}
return user;
}getSessionById у цьому прикладі має бути серверною функцією, яка звертається до бази даних або іншого сховища сесій. Її не можна імпортувати в Client Component.
Приховування кнопки в Client Component не захищає операцію:
"use client";
export function AdminButton({ user }) {
if (user.role !== "admin") {
return null;
}
return <button>Видалити користувача</button>;
}Зловмисник може безпосередньо надіслати запит до endpoint. Перевірка ролі повинна виконуватися в Server Action або Route Handler безпосередньо перед зміною даних.
redirect як перевірки безпекиredirect() змінює навігацію, але не замінює перевірку доступу. У Route Handler або Server Action спочатку потрібно перевірити користувача, потім виконати або відхилити операцію.
Для неавторизованого користувача зазвичай використовують:
401 Unauthorized, якщо особу не встановлено;
перенаправлення на /login для сторінок;
403 Forbidden, якщо користувач автентифікований, але не має потрібних прав.
redirect у try/catchУ Next.js redirect() сигналізує про перенаправлення спеціальним внутрішнім винятком. Якщо охопити його загальним try/catch, редирект можна випадково зламати.
Неправильно:
try {
await saveData();
redirect("/dashboard");
} catch (error) {
return { error: "Не вдалося зберегти дані" };
}Безпечніше розміщувати redirect після блоку, який обробляє помилки:
try {
await saveData();
} catch (error) {
return { error: "Не вдалося зберегти дані" };
}
redirect("/dashboard");Не можна без перевірки використовувати адресу з query-параметра:
redirect(searchParams.get("next"));Атакер може підставити зовнішній URL і використати застосунок для фішингу.
Дозволяйте лише локальні шляхи:
function getSafeRedirect(value) {
if (!value || !value.startsWith("/") || value.startsWith("//")) {
return "/dashboard";
}
return value;
}Для складніших сценаріїв додатково перевіряйте, що шлях належить до дозволеного набору маршрутів.
Найнебезпечніша помилка — повернути персоналізовану відповідь із кешем, який не відрізняє користувачів.
Поганий приклад:
const response = await fetch("https://api.example.test/account");Якщо відповідь кешується, наступний користувач може отримати дані попереднього.
Для даних, залежних від cookie або поточного користувача, явно вимикайте кеш:
const response = await fetch("https://api.example.test/account", {
cache: "no-store",
});Для власного Route Handler:
// app/api/me/route.js
import { NextResponse } from "next/server";
import { getCurrentUser } from "@/lib/auth";
export async function GET() {
const user = await getCurrentUser();
if (!user) {
return NextResponse.json(
{ error: "Не автентифіковано" },
{
status: 401,
headers: {
"Cache-Control": "no-store",
},
},
);
}
return NextResponse.json(
{
id: user.id,
name: user.name,
role: user.role,
},
{
headers: {
"Cache-Control": "private, no-store",
},
},
);
}private забороняє спільним кешам використовувати відповідь, а no-store вимагає не зберігати її. Для відповідей із приватними даними найнадійніше використовувати обидва значення.
unstable_cache для даних користувачаНе кешуйте запит із даними користувача без ключа, який однозначно враховує користувача. Інакше один кешований результат може стати спільним для всіх.
Особливо небезпечно кешувати функцію, яка всередині читає cookie, але ключ кешу не містить ідентифікатор користувача або сесії.
Загальне правило:
публічні дані можна кешувати спільно;
персоналізовані дані потрібно не кешувати або кешувати ізольовано;
дозвіл на операцію не можна брати з довгоживучого кешу.
Виклик cookies() робить маршрут залежним від запиту, але це не привід покладатися лише на неявну поведінку фреймворку. Для запитів до API, бази даних і зовнішніх сервісів, які повертають приватні дані, задавайте політику явно.
// app/dashboard/page.js
import { requireUser } from "@/lib/auth";
import { getPrivateProjects } from "@/lib/projects";
export const dynamic = "force-dynamic";
export default async function DashboardPage() {
const user = await requireUser();
const projects = await getPrivateProjects(user.id);
return (
<main>
<h1>Проєкти користувача</h1>
<ul>
{projects.map((project) => (
<li key={project.id}>{project.name}</li>
))}
</ul>
</main>
);
}dynamic = "force-dynamic" корисний, коли сторінка завжди має формуватися на сервері для поточного запиту. Водночас запити до приватного API все одно варто робити з cache: "no-store".
Не довіряйте:
role у body запиту;
hidden input;
значенням із localStorage;
props, які можна змінити в браузері;
заголовкам, які клієнт може самостійно сформувати.
Неправильно:
export async function deleteUser(formData) {
const role = formData.get("role");
if (role !== "admin") {
throw new Error("Недостатньо прав");
}
// Видалення користувача
}Роль потрібно отримувати із перевіреної серверної сесії або з бази даних.
Навіть якщо /admin захищений, endpoint /api/admin/users теж повинен перевіряти права. URL, Server Component і Route Handler — це різні точки входу.
Приклад захищеного endpoint:
// app/api/admin/users/[id]/route.js
import { NextResponse } from "next/server";
import { getCurrentUser } from "@/lib/auth";
import { deleteUser } from "@/lib/users";
export async function DELETE(request, { params }) {
const user = await getCurrentUser();
if (!user) {
return NextResponse.json(
{ error: "Не автентифіковано" },
{ status: 401 },
);
}
if (user.role !== "admin") {
return NextResponse.json(
{ error: "Недостатньо прав" },
{ status: 403 },
);
}
await deleteUser(params.id);
return new NextResponse(null, { status: 204 });
}Захист потрібно виконати до deleteUser, а не після нього.
Роль editor не обов’язково означає право редагувати будь-який документ. Часто потрібно перевірити:
чи має користувач потрібну роль;
чи належить ресурс потрібній організації;
чи має користувач доступ саме до цього ресурсу;
чи дозволена конкретна операція.
Правильна перевірка має бути прив’язана до ресурсу:
const document = await getDocument(documentId);
if (!document) {
return new Response("Не знайдено", { status: 404 });
}
if (document.ownerId !== user.id && user.role !== "admin") {
return new Response("Недостатньо прав", { status: 403 });
}Не покладайтеся на ідентифікатор ресурсу, отриманий від клієнта, без перевірки його належності.
Server Action або Route Handler може бути викликаний вручну багато разів. Для операцій, які не повинні повторюватися, використовуйте:
перевірку поточного стану;
унікальні обмеження в базі даних;
ідемпотентний ідентифікатор операції;
транзакцію для пов’язаних змін.
Аутентифікація сама по собі не робить операцію одноразовою.
Після login часто передають next. Його потрібно валідовувати до виклику redirect, а не вставляти безпосередньо у відповідь.
// app/login/actions.js
"use server";
import { redirect } from "next/navigation";
import { authenticate } from "@/lib/authenticate";
function safePath(value) {
if (typeof value !== "string") {
return "/dashboard";
}
if (!value.startsWith("/") || value.startsWith("//")) {
return "/dashboard";
}
return value;
}
export async function loginAction(formData) {
const email = String(formData.get("email") ?? "");
const password = String(formData.get("password") ?? "");
const next = safePath(formData.get("next"));
const result = await authenticate(email, password);
if (!result.ok) {
return {
error: "Неправильна електронна пошта або пароль",
};
}
redirect(next);
}Функція authenticate у цьому прикладі повинна:
перевірити пароль за допомогою безпечного password-hashing алгоритму;
створити нову серверну сесію;
встановити httpOnly cookie;
не повертати різні помилки для неіснуючого email і неправильного пароля.
Якщо браузер автоматично надсилає cookie, зміна стану може бути піддана CSRF-атаці. Для операцій POST, PUT, PATCH і DELETE:
використовуйте SameSite=Lax або суворіше значення, якщо це сумісно з архітектурою;
перевіряйте Origin або Referer на сервері;
для складніших сценаріїв використовуйте CSRF-токен;
не виконуйте зміни стану через GET.
Приклад простої перевірки Origin у Route Handler:
function isAllowedOrigin(request) {
const origin = request.headers.get("origin");
const expected = process.env.APP_ORIGIN;
return Boolean(origin && expected && origin === expected);
}
export async function POST(request) {
if (!isAllowedOrigin(request)) {
return new Response("Недозволене джерело запиту", { status: 403 });
}
// Тут додатково перевіряється сесія та виконуються зміни.
}Перевірка Origin не замінює автентифікацію та авторизацію. Вона лише додає захист від запитів із небажаного походження.
Middleware або Proxy зручний для:
швидкого перенаправлення явно неавторизованих запитів;
перевірки базової наявності cookie;
захисту групи маршрутів від зайвого рендерингу.
Але це не повинно бути єдиним рівнем безпеки. Cookie може бути підроблена, прострочена або відкликана. Остаточну перевірку потрібно виконувати поруч із даними та операцією:
у Server Component перед отриманням приватних даних;
у Route Handler перед відповіддю;
у Server Action перед зміною стану.
Не робіть складний запит до бази даних у Middleware для кожного ресурсу без потреби. Це може збільшити затримку і створити проблеми в середовищах виконання.
Коли authentication працює нестабільно, перевіряйте систему в такому порядку:
Cookie
Чи встановлюється вона у відповіді?
Чи правильні path, secure, sameSite і термін дії?
Чи передається вона саме на потрібний домен і маршрут?
Сесія
Чи існує запис у сховищі?
Чи не завершився термін дії?
Чи не була сесія відкликана?
Чи відбувається ротація після login?
Редирект
Чи не перехоплюється redirect() у try/catch?
Чи не використовується неперевірений next?
Чи розрізняються випадки 401 і 403?
Кеш
Чи не кешується приватний fetch?
Чи не використовується спільний unstable_cache для даних різних користувачів?
Чи встановлено Cache-Control: no-store для приватних відповідей?
Права
Чи перевіряється користувач на сервері?
Чи перевіряється роль або дозвіл безпосередньо перед операцією?
Чи перевіряється доступ саме до потрібного ресурсу?
localStorageТакі значення доступні JavaScript-коду сторінки. Після XSS-атаки їх можна викрасти. Для сесійної cookie використовуйте httpOnly.
middleware захищає APIMiddleware може пропустити запит через помилку конфігурації або перевірити лише наявність cookie. Endpoint повинен сам перевірити сесію та права.
Під час login не розрізняйте повідомлення для:
неіснуючої електронної пошти;
неправильного пароля;
заблокованого облікового запису.
Інакше можна спростити визначення зареєстрованих користувачів.
UI-контроль покращує досвід користувача, але не є механізмом безпеки. Будь-який дозвіл перевіряється на сервері.
Якщо відповідь залежить від cookie, користувача або ролі, явна політика no-store зазвичай безпечніша за припущення щодо поведінки кешу.
Cookie сесії повинна мати httpOnly, правильний secure, sameSite, path і обмежений час життя.
Наявність cookie не доводить, що сесія дійсна.
Logout має інвалідувати сесію на сервері та видаляти cookie.
redirect() не замінює авторизацію і не повинен перехоплюватися загальним try/catch.
URL для next потрібно перевіряти, щоб уникнути open redirect.
Персоналізовані дані не можна віддавати через спільний кеш.
Перевірка прав повинна виконуватися на сервері безпосередньо перед операцією.
Сторінки, Server Actions і Route Handlers потребують незалежної перевірки доступу.
Автентифікація, авторизація, захист від CSRF і контроль кешування — різні рівні захисту, які потрібно реалізовувати разом.