Пошук уроків, статей та іншого контенту
Захистіть API за допомогою сесій або токенів і реалізуйте перевірку автентифікації в Route Handler.
Автентифікація відповідає на запитання: хто виконує запит. Авторизація визначає, що цьому користувачу дозволено робити.
Route Handler може перевіряти автентифікацію за допомогою:
сесії — клієнт надсилає ідентифікатор сесії в cookie, а сервер знаходить відповідного користувача;
токена — клієнт надсилає токен, зазвичай у заголовку Authorization.
У цьому уроці використаємо сесію в httpOnly cookie. Клієнт не зможе прочитати таку cookie через JavaScript, але браузер автоматично надсилатиме її разом із запитами до API.
Створимо такі файли:
app/
api/
login/
route.ts
logout/
route.ts
me/
route.ts
lib/
session.tsПриклад містить спрощене сховище сесій у пам’яті процесу. Це зручно для навчання, але не підходить для production: після перезапуску сервера всі сесії зникнуть, а в кількох екземплярах застосунку вони не будуть спільними.
У production сесії слід зберігати в базі даних або спеціалізованому сховищі, наприклад Redis.
Файл lib/session.ts:
import { randomUUID } from "node:crypto";
export type AuthenticatedUser = {
id: string;
email: string;
};
type Session = {
user: AuthenticatedUser;
expiresAt: number;
};
// Демонстраційне сховище. У production використовуйте базу даних або Redis.
const sessions = new Map<string, Session>();
const SESSION_DURATION_MS = 1000 * 60 * 60 * 24;
export function createSession(user: AuthenticatedUser) {
const sessionId = randomUUID();
sessions.set(sessionId, {
user,
expiresAt: Date.now() + SESSION_DURATION_MS,
});
return {
sessionId,
expiresAt: new Date(Date.now() + SESSION_DURATION_MS),
};
}
export function getSession(sessionId: string) {
const session = sessions.get(sessionId);
if (!session) {
return null;
}
if (session.expiresAt <= Date.now()) {
sessions.delete(sessionId);
return null;
}
return session;
}
export function deleteSession(sessionId: string) {
sessions.delete(sessionId);
}randomUUID() створює випадковий ідентифікатор сесії. Клієнт отримує лише цей ідентифікатор у cookie, а дані користувача зберігаються на сервері.
Створимо app/api/login/route.ts:
import { NextRequest, NextResponse } from "next/server";
import { createSession } from "@/lib/session";
type LoginBody = {
email?: string;
password?: string;
};
export async function POST(request: NextRequest) {
let body: LoginBody;
try {
body = await request.json();
} catch {
return NextResponse.json(
{ error: "Тіло запиту має бути коректним JSON" },
{ status: 400 },
);
}
const email = body.email?.trim();
const password = body.password;
if (!email || !password) {
return NextResponse.json(
{ error: "Email і пароль є обов'язковими" },
{ status: 400 },
);
}
// Демонстраційна перевірка. У production пароль потрібно перевіряти
// через базу даних і хеш пароля.
const isValidCredentials =
email === "demo@example.com" &&
password === "correct-horse-battery-staple";
if (!isValidCredentials) {
return NextResponse.json(
{ error: "Неправильний email або пароль" },
{ status: 401 },
);
}
const user = {
id: "user-1",
email,
};
const { sessionId, expiresAt } = createSession(user);
const response = NextResponse.json({
user,
});
response.cookies.set({
name: "session_id",
value: sessionId,
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
expires: expiresAt,
path: "/",
});
return response;
}Тепер POST-запит до /api/login:
{
"email": "demo@example.com",
"password": "correct-horse-battery-staple"
}створить сесію та поверне відповідь із cookie session_id.
httpOnly: true забороняє доступ до cookie з клієнтського JavaScript.
secure: true змушує браузер надсилати cookie лише через HTTPS. У локальній розробці це значення зазвичай вимикають.
sameSite: "lax" обмежує надсилання cookie під час більшості cross-site запитів.
expires задає час завершення сесії.
path: "/" робить cookie доступною для всіх маршрутів застосунку.
Винесемо перевірку сесії в окрему функцію. Створимо файл lib/auth.ts:
import { cookies } from "next/headers";
import { getSession } from "@/lib/session";
export async function getAuthenticatedUser() {
const cookieStore = await cookies();
const sessionId = cookieStore.get("session_id")?.value;
if (!sessionId) {
return null;
}
const session = getSession(sessionId);
if (!session) {
return null;
}
return session.user;
}Тепер ця функція:
читає cookie session_id;
знаходить сесію за її ідентифікатором;
перевіряє, чи не завершився термін дії сесії;
повертає користувача або null.
Створимо захищений Route Handler app/api/me/route.ts:
import { NextResponse } from "next/server";
import { getAuthenticatedUser } from "@/lib/auth";
export async function GET() {
const user = await getAuthenticatedUser();
if (!user) {
return NextResponse.json(
{ error: "Потрібна автентифікація" },
{ status: 401 },
);
}
return NextResponse.json({ user });
}Якщо клієнт надсилає запит із дійсною cookie:
GET /api/meRoute Handler повертає:
{
"user": {
"id": "user-1",
"email": "demo@example.com"
}
}Без cookie або з недійсною сесією відповідь буде такою:
{
"error": "Потрібна автентифікація"
}HTTP-статус у цьому випадку — 401 Unauthorized.
Під час виходу потрібно:
видалити сесію зі сховища;
видалити cookie в браузері.
Файл app/api/logout/route.ts:
import { cookies } from "next/headers";
import { NextResponse } from "next/server";
import { deleteSession } from "@/lib/session";
export async function POST() {
const cookieStore = await cookies();
const sessionId = cookieStore.get("session_id")?.value;
if (sessionId) {
deleteSession(sessionId);
}
const response = NextResponse.json({ success: true });
response.cookies.set({
name: "session_id",
value: "",
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
expires: new Date(0),
path: "/",
});
return response;
}Встановлення дати new Date(0) у минулому змушує браузер видалити cookie.
У браузері cookie надсилається автоматично для запитів до того самого домену:
const response = await fetch("/api/me");
if (response.status === 401) {
console.log("Користувач не автентифікований");
} else {
const data = await response.json();
console.log(data.user);
}Для запиту до іншого домену потрібно явно дозволити надсилання cookie:
const response = await fetch("https://api.example.com/me", {
credentials: "include",
});Однак сервер також має коректно налаштувати CORS. Не слід використовувати Access-Control-Allow-Origin: * разом із cookie.
У Route Handler варто розрізняти основні типи помилок:
400 Bad Request — запит має неправильний формат або не містить обов’язкових полів;
401 Unauthorized — користувач не ввійшов у систему або сесія недійсна;
403 Forbidden — користувач автентифікований, але не має дозволу на операцію.
Перевірка автентифікації має відбуватися до виконання захищеної операції:
export async function DELETE() {
const user = await getAuthenticatedUser();
if (!user) {
return NextResponse.json(
{ error: "Потрібна автентифікація" },
{ status: 401 },
);
}
// Тут можна виконувати операцію від імені user.id.
return NextResponse.json({ success: true });
}Не можна покладатися на userId, який прийшов у тілі запиту. Користувач може змінити це значення. Ідентифікатор автентифікованого користувача потрібно отримувати із серверної сесії.
Сесія і токен мають різні підходи до зберігання стану.
При використанні сесії:
сервер створює випадковий ідентифікатор;
зберігає зв’язок між ідентифікатором і користувачем;
надсилає ідентифікатор у cookie;
перевіряє ідентифікатор у кожному захищеному запиті.
Перевага сесій — їх можна негайно відкликати, видаливши запис зі сховища.
При використанні токена клієнт надсилає його в заголовку:
Authorization: Bearer <token>Route Handler має:
прочитати заголовок Authorization;
перевірити формат Bearer;
перевірити підпис і термін дії токена;
отримати ідентифікатор користувача;
перевірити дозволи.
Не слід приймати довільний рядок із заголовка як підтвердження особи. Токен має бути криптографічно захищеним і перевірятися сервером або надійною бібліотекою.
Для браузерного застосунку сесія в httpOnly cookie часто є простішим і безпечнішим рішенням, ніж зберігання токена в localStorage.
Наведений приклад демонструє механізм перевірки сесій, але для production потрібно врахувати такі правила:
не зберігати паролі у відкритому вигляді;
порівнювати пароль із хешем, отриманим під час реєстрації;
зберігати сесії у спільному сховищі, якщо застосунок працює на кількох екземплярах;
використовувати HTTPS;
встановлювати короткий і обґрунтований термін дії сесії;
не повертати зайві дані про причину невдалої автентифікації;
для cookie-автентифікації захищати небезпечні cross-site запити від CSRF;
не розміщувати секрети та паролі безпосередньо в коді.
Окремо слід пам’ятати, що перевірка автентифікації в інтерфейсі не захищає API. Кожен Route Handler, який працює з приватними даними, повинен перевіряти сесію на сервері.
Приховування кнопки в React-компоненті не захищає endpoint. Користувач все одно може вручну надіслати HTTP-запит.
Правильно: перевіряти сесію всередині кожного захищеного Route Handler.
401 для відсутності дозволуЯкщо користувач не ввійшов у систему, використовуйте 401.
Якщо користувач увійшов, але не має доступу до конкретного ресурсу, використовуйте 403.
Такі значення, як userId, role або isAdmin, не можна вважати достовірними, якщо вони прийшли з тіла запиту, query-параметрів або звичайних заголовків.
Ці дані потрібно отримувати із перевіреної сесії або токена.
httpOnlyCookie із сесійним ідентифікатором не повинна бути доступною клієнтському JavaScript. Інакше XSS-вразливість може дозволити викрасти сесію.
Map підходить для локального прикладу, але має суттєві обмеження:
сесії зникають після перезапуску;
різні екземпляри сервера не бачать сесії один одного;
сховище може необмежено зростати без очищення.
Для production використовуйте спільне постійне сховище.
Route Handler повинен перевіряти автентифікацію на сервері.
Сесія зазвичай складається з випадкового ідентифікатора в httpOnly cookie та запису на сервері.
cookies() у сучасному Next.js використовується асинхронно.
Для неавтентифікованого запиту слід повертати 401 Unauthorized.
Під час виходу потрібно видалити і серверну сесію, і cookie.
Дані користувача потрібно отримувати з перевіреної сесії, а не з тіла запиту.
Сховище сесій у пам’яті придатне лише для демонстрації, а не для production.