Пошук уроків, статей та іншого контенту
Застосуєте захист від CSRF, XSS, session fixation, витоку секретів і brute-force атак в authentication flow.
Authentication flow у Next.js зазвичай використовує cookie-сесію. Браузер автоматично додає таку cookie до запитів, тому необхідно захистити:
операції, які змінюють стан, від CSRF;
HTML, URL і повідомлення про помилки від XSS;
сесію під час переходу з анонімного стану в автентифікований;
секрети від потрапляння до клієнтського бандла та логів;
endpoint входу від brute-force атак.
Захист має бути на сервері. Перевірки на клієнті покращують UX, але не є механізмом безпеки.
SameSite недостатньоCookie з параметром SameSite=Lax або SameSite=Strict значно зменшує ризик CSRF, але не повинна бути єдиним захистом:
різні частини застосунку можуть мати різні вимоги до SameSite;
legacy-браузери або проксі можуть поводитися інакше;
деякі endpoint-и можуть приймати запити не лише через браузерні форми;
помилка в конфігурації cookie може знову відкрити CSRF.
Для state-changing операцій використовуйте комбінацію:
HttpOnly cookie для сесії;
SameSite=Lax або SameSite=Strict;
перевірку Origin;
CSRF-токен у заголовку або тілі запиту.
Один із практичних підходів — double-submit cookie:
сервер створює CSRF-токен;
токен записується в cookie, доступну JavaScript;
клієнт надсилає це саме значення в заголовку;
сервер порівнює cookie і заголовок та перевіряє підпис токена.
CSRF-cookie не повинна містити даних користувача або session ID. Вона може бути доступною JavaScript, але cookie сесії обов’язково має бути HttpOnly.
Приклад допоміжного модуля:
// lib/csrf.ts
import {
createHmac,
randomBytes,
timingSafeEqual,
} from "node:crypto";
const csrfSecret = process.env.CSRF_SECRET;
if (!csrfSecret) {
throw new Error("CSRF_SECRET is not configured");
}
function sign(value: string): string {
return createHmac("sha256", csrfSecret)
.update(value)
.digest("base64url");
}
export function createCsrfToken(): string {
const nonce = randomBytes(32).toString("base64url");
return `${nonce}.${sign(nonce)}`;
}
export function isValidCsrfToken(
tokenFromCookie: string | undefined,
tokenFromHeader: string | undefined,
): boolean {
if (!tokenFromCookie || !tokenFromHeader) {
return false;
}
// Обидва значення мають бути однаковими до криптографічної перевірки.
if (tokenFromCookie !== tokenFromHeader) {
return false;
}
const separatorIndex = tokenFromCookie.lastIndexOf(".");
if (separatorIndex <= 0) {
return false;
}
const nonce = tokenFromCookie.slice(0, separatorIndex);
const receivedSignature = tokenFromCookie.slice(separatorIndex + 1);
const expectedSignature = sign(nonce);
const received = Buffer.from(receivedSignature);
const expected = Buffer.from(expectedSignature);
if (received.length !== expected.length) {
return false;
}
return timingSafeEqual(received, expected);
}Route Handler для видачі токена:
// app/api/csrf/route.ts
import { NextResponse } from "next/server";
import { createCsrfToken } from "@/lib/csrf";
export async function GET() {
const token = createCsrfToken();
const response = NextResponse.json({ csrfToken: token });
response.cookies.set("csrf-token", token, {
httpOnly: false,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
path: "/",
maxAge: 60 * 60,
});
return response;
}Перед POST, PUT, PATCH або DELETE клієнт отримує токен і надсилає його в заголовку:
const csrfResponse = await fetch("/api/csrf");
const { csrfToken } = await csrfResponse.json();
const response = await fetch("/api/profile", {
method: "PATCH",
headers: {
"Content-Type": "application/json",
"X-CSRF-Token": csrfToken,
},
body: JSON.stringify({ displayName: "Олена" }),
});На сервері перевіряйте не лише токен, а й джерело запиту:
const origin = request.headers.get("origin");
const expectedOrigin = process.env.APP_ORIGIN;
if (origin !== expectedOrigin) {
return NextResponse.json(
{ error: "Недозволене джерело запиту" },
{ status: 403 },
);
}
const csrfCookie = request.cookies.get("csrf-token")?.value;
const csrfHeader = request.headers.get("x-csrf-token");
if (!isValidCsrfToken(csrfCookie, csrfHeader)) {
return NextResponse.json(
{ error: "Недійсний CSRF-токен" },
{ status: 403 },
);
}Для запитів без cookie-автентифікації CSRF-захист зазвичай не потрібен. Він потрібен саме тоді, коли браузер автоматично додає автентифікаційні дані до запиту.
React автоматично екранує текстові значення:
export function ProfileName({ name }: { name: string }) {
return <h1>{name}</h1>;
}Значення на кшталт <script>alert(1)</script> буде відображене як текст, а не виконане.
Небезпечними є:
dangerouslySetInnerHTML;
вставлення неперевіреного значення в href або src;
HTML, отриманий від користувача;
використання eval, new Function або виконання рядків як JavaScript;
ручне формування HTML через конкатенацію рядків.
Не використовуйте dangerouslySetInnerHTML для звичайного тексту:
// Погано: значення може містити шкідливий HTML.
<div dangerouslySetInnerHTML={{ __html: comment.body }} />Якщо HTML справді потрібен, його слід очищати спеціалізованим HTML sanitizer-ом на сервері, дозволяючи лише необхідні теги й атрибути. Простого видалення рядка <script> недостатньо: XSS може використовувати атрибути, URL-схеми та некоректно оброблені SVG.
Не вставляйте неперевірений URL у посилання:
// Небезпечно: значення може бути javascript:-URL.
<a href={redirectUrl}>Продовжити</a>Перевіряйте протокол і, якщо це внутрішня навігація, дозволяйте лише локальні шляхи:
export function isSafeLocalPath(value: string): boolean {
return value.startsWith("/") && !value.startsWith("//");
}Для redirect після входу:
const nextPath = requestUrl.searchParams.get("next");
const destination = nextPath && isSafeLocalPath(nextPath)
? nextPath
: "/account";Так ви уникаєте open redirect, який може використовуватися для фішингу.
CSP додає ще один рівень захисту, обмежуючи джерела скриптів, стилів, зображень і фреймів. Для authentication-сторінок особливо корисними є:
object-src 'none';
base-uri 'self';
frame-ancestors 'none';
обмежений script-src;
заборона завантаження ресурсів із невідомих доменів.
У production CSP краще генерувати з nonce для кожного запиту. Не додавайте без необхідності 'unsafe-eval' або 'unsafe-inline': вони послаблюють політику і можуть звести її захисний ефект нанівець.
Session fixation виникає, коли атакер заздалегідь знає session ID жертви, а після входу цей самий ID продовжує використовуватися як автентифікований.
Правило захисту:
Після успішної автентифікації потрібно знищити стару сесію і створити нову.
Сесійна cookie має бути налаштована так:
response.cookies.set("session", sessionId, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
path: "/",
maxAge: 60 * 60 * 24 * 7,
});Не зберігайте в cookie пароль, email або інші довільні дані. Краще зберігати випадковий непрозорий session ID, а стан сесії — на сервері.
Приклад логіки ротації:
const oldSessionId = request.cookies.get("session")?.value;
if (oldSessionId) {
// Стара анонімна або частково створена сесія більше не повинна діяти.
await sessionStore.delete(oldSessionId);
}
const newSessionId = crypto.randomUUID();
await sessionStore.create(newSessionId, {
userId: user.id,
createdAt: Date.now(),
});
response.cookies.set("session", newSessionId, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
path: "/",
maxAge: 60 * 60 * 24 * 7,
});Після зміни привілеїв, повторної автентифікації або відновлення пароля також доцільно відкликати старі сесії.
У Next.js змінні, назви яких починаються з NEXT_PUBLIC_, можуть потрапити до клієнтського бандла. Там не повинні знаходитися:
секрети підпису JWT;
ключі шифрування;
паролі до бази даних;
ключі адміністративних API;
pepper для хешування паролів;
токени доступу до приватних сервісів.
Серверні секрети використовуйте лише в Server Components, Route Handlers або іншому серверному коді:
const sessionSecret = process.env.SESSION_SECRET;
if (!sessionSecret) {
throw new Error("SESSION_SECRET is not configured");
}Не використовуйте:
NEXT_PUBLIC_SESSION_SECRET=...Зберігайте секрети в Secret Manager або захищених змінних середовища deployment-платформи.
Не комітьте .env, приватні ключі та резервні копії секретів.
Не виводьте cookie, authorization header і повний request body у логи.
Не передавайте секрети в props Client Component.
Не вставляйте секрети у винятки, повідомлення API або назви telemetry-подій.
Для ротації секретів підтримуйте короткий період, коли дійсні старий і новий ключі, якщо це необхідно для безперервної роботи.
Використовуйте різні секрети для development, staging і production.
Якщо змінна потрібна лише серверу, не додавайте до її назви NEXT_PUBLIC_.
Захист від brute-force має застосовуватися до endpoint-а входу, відновлення пароля, підтвердження коду та інших операцій, що перевіряють секрет.
Ліміт можна застосовувати за комбінацією:
IP-адреси;
нормалізованого email або username;
IP-адреси та ідентифікатора користувача;
пристрою або додаткового risk signal.
Не покладайтеся лише на IP: користувачі можуть бути за спільним NAT. Не покладайтеся лише на email: атакер може змінювати його в кожному запиті.
Лімітер має працювати у спільному сховищі, наприклад Redis або іншому зовнішньому rate-limit сервісі. Map у пам’яті процесу підходить лише для локальної демонстрації: він не синхронізується між інстансами і скидається після перезапуску.
Спрощений контракт лімітера:
type RateLimitResult = {
allowed: boolean;
retryAfterSeconds: number;
};
export async function checkLoginRateLimit(
key: string,
): Promise<RateLimitResult> {
// У production цей стан має зберігатися у спільному сховищі.
const attempts = await rateLimitStore.increment(key, 15 * 60);
if (attempts > 5) {
return {
allowed: false,
retryAfterSeconds: 15 * 60,
};
}
return {
allowed: true,
retryAfterSeconds: 0,
};
}Значення Retry-After можна повернути клієнту:
return NextResponse.json(
{ error: "Забагато спроб. Повторіть пізніше." },
{
status: 429,
headers: {
"Retry-After": String(result.retryAfterSeconds),
},
},
);Повідомлення для неправильного email і неправильного пароля повинно бути однаковим:
Неправильний email або пароль.Не використовуйте різні відповіді:
Користувача не знайдено.
Пароль неправильний.Інакше атакер зможе виконувати user enumeration. Час обробки також бажано вирівнювати: якщо користувача не знайдено, перевірте пароль на фіктивному хеші, щоб відмінність часу не розкривала наявність облікового запису.
Паролі зберігайте лише у вигляді повільного адаптивного хешу, наприклад Argon2id або bcrypt. Не використовуйте для паролів SHA-256 без спеціального password hashing алгоритму.
Нижче показано структуру endpoint-а. userStore, sessionStore і rateLimitStore мають бути реалізовані на сервері; для production вони повинні використовувати спільну базу даних або сховище.
// app/api/login/route.ts
import { randomUUID } from "node:crypto";
import { NextRequest, NextResponse } from "next/server";
import argon2 from "argon2";
import { isValidCsrfToken } from "@/lib/csrf";
import { checkLoginRateLimit } from "@/lib/rate-limit";
import { userStore, sessionStore } from "@/lib/server-stores";
const appOrigin = process.env.APP_ORIGIN;
if (!appOrigin) {
throw new Error("APP_ORIGIN is not configured");
}
// Цей хеш використовується для однакової поведінки, коли email не існує.
const dummyPasswordHash = process.env.DUMMY_PASSWORD_HASH;
if (!dummyPasswordHash) {
throw new Error("DUMMY_PASSWORD_HASH is not configured");
}
export async function POST(request: NextRequest) {
const origin = request.headers.get("origin");
if (origin !== appOrigin) {
return NextResponse.json(
{ error: "Недозволене джерело запиту" },
{ status: 403 },
);
}
const csrfCookie = request.cookies.get("csrf-token")?.value;
const csrfHeader = request.headers.get("x-csrf-token");
if (!isValidCsrfToken(csrfCookie, csrfHeader)) {
return NextResponse.json(
{ error: "Недійсний CSRF-токен" },
{ status: 403 },
);
}
let body: { email?: unknown; password?: unknown };
try {
body = await request.json();
} catch {
return NextResponse.json(
{ error: "Некоректне тіло запиту" },
{ status: 400 },
);
}
const email =
typeof body.email === "string"
? body.email.trim().toLowerCase()
: "";
const password =
typeof body.password === "string"
? body.password
: "";
if (!email || !password || password.length > 1024) {
return NextResponse.json(
{ error: "Неправильний email або пароль" },
{ status: 401 },
);
}
const ip = request.headers.get("x-forwarded-for")?.split(",")[0].trim()
?? "unknown";
const rateLimit = await checkLoginRateLimit(`${ip}:${email}`);
if (!rateLimit.allowed) {
return NextResponse.json(
{ error: "Забагато спроб. Повторіть пізніше." },
{
status: 429,
headers: {
"Retry-After": String(rateLimit.retryAfterSeconds),
},
},
);
}
const user = await userStore.findByEmail(email);
const passwordHash = user?.passwordHash ?? dummyPasswordHash;
const passwordMatches = await argon2.verify(passwordHash, password);
if (!user || !passwordMatches) {
return NextResponse.json(
{ error: "Неправильний email або пароль" },
{ status: 401 },
);
}
const oldSessionId = request.cookies.get("session")?.value;
if (oldSessionId) {
// Відкликаємо стару сесію, щоб session fixation не дала доступ після входу.
await sessionStore.delete(oldSessionId);
}
const newSessionId = randomUUID();
await sessionStore.create(newSessionId, {
userId: user.id,
createdAt: Date.now(),
});
const response = NextResponse.json({ ok: true });
response.cookies.set("session", newSessionId, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
path: "/",
maxAge: 60 * 60 * 24 * 7,
});
return response;
}Цей приклад демонструє послідовність перевірок:
дозволене джерело запиту;
CSRF-токен;
формат вхідних даних;
rate limit;
перевірка пароля;
відкликання старої сесії;
створення нового session ID;
встановлення захищеної cookie.
Вважати SameSite повною заміною CSRF-токена.
Зберігати session ID у localStorage.
Встановлювати session cookie без HttpOnly.
Не змінювати session ID після успішного входу.
Використовувати один глобальний секрет у всіх середовищах.
Додавати секрет до змінної NEXT_PUBLIC_*.
Логувати весь об’єкт запиту разом із cookies та заголовками.
Відрізняти повідомлення «користувача не знайдено» від «неправильний пароль».
Реалізовувати rate limit через локальний Map у production Next.js.
Вставляти користувацький HTML через dangerouslySetInnerHTML без санітизації.
Дозволяти довільні значення для redirect-параметра.
Використовувати Access-Control-Allow-Origin: * разом із credentialed cookies.
Вважати перевірку на клієнті достатньою для пароля, ролі або CSRF.
Для cookie-based authentication використовуйте HttpOnly, Secure і SameSite.
Для state-changing запитів перевіряйте Origin і CSRF-токен.
Не вставляйте неперевірені значення як HTML або довільні URL.
Після входу ротируйте session ID і відкликайте стару сесію.
Серверні секрети не повинні мати префікс NEXT_PUBLIC_.
Rate limit реалізовуйте у спільному сховищі та повертайте загальне повідомлення про помилку.
Паролі зберігайте через Argon2id або bcrypt, а не як звичайний текст чи швидкий хеш.
Захисні перевірки authentication flow мають виконуватися на сервері.