Повний ландшафт автентифікації: коли використовувати сесії vs JWT, потоки OAuth 2.0 / OIDC, ротація refresh token, passkeys (WebAuthn) та патерни автентифікації Next.js.
Автентифікація — це єдина сфера веб-розробки, де «воно працює» ніколи не є достатнім. Баг у твоєму date picker — це прикрість. Баг у твоїй системі автентифікації — це витік даних.
Я реалізовував автентифікацію з нуля, мігрував між провайдерами, дебажив інциденти крадіжки токенів і мав справу з наслідками рішень «ми полагодимо безпеку пізніше». Цей пост — вичерпний посібник, який я хотів би мати, коли починав. Не просто теорія — реальні компроміси, реальні вразливості та патерни, що витримують тиск продакшену.
Ми охопимо весь ландшафт: сесії, JWT, OAuth 2.0, passkeys, MFA та авторизацію. Наприкінці ти зрозумієш не тільки як кожен механізм працює, а й коли його використовувати і чому існують альтернативи.
Це перше рішення, з яким ти зіткнешся, і в інтернеті повно поганих порад щодо нього. Дозволь розкласти те, що насправді має значення.
Сесії — це оригінальний підхід. Сервер створює запис сесії, зберігає його десь (база даних, Redis, пам'ять) і надає клієнту непрозорий ідентифікатор сесії в cookie.
// Спрощене створення сесії
import { randomBytes } from "crypto";
import { cookies } from "next/headers";
interface Session {
userId: string;
createdAt: Date;
expiresAt: Date;
ipAddress: string;
userAgent: string;
}
async function createSession(userId: string, request: Request): Promise<string> {
const sessionId = randomBytes(32).toString("hex");
const session: Session = {
userId,
createdAt: new Date(),
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000), // 24 години
ipAddress: request.headers.get("x-forwarded-for") ?? "unknown",
userAgent: request.headers.get("user-agent") ?? "unknown",
};
// Зберігаємо у базі даних або Redis
await redis.set(`session:${sessionId}`, JSON.stringify(session), "EX", 86400);
const cookieStore = await cookies();
cookieStore.set("session_id", sessionId, {
httpOnly: true,
secure: true,
sameSite: "lax",
maxAge: 86400,
path: "/",
});
return sessionId;
}Переваги реальні:
Недоліки теж реальні:
JWT перевертають модель. Замість зберігання стану сесії на сервері, ти кодуєш його в підписаний токен, який тримає клієнт.
import { SignJWT, jwtVerify } from "jose";
const secret = new TextEncoder().encode(process.env.JWT_SECRET);
async function createAccessToken(userId: string, role: string): Promise<string> {
return new SignJWT({ sub: userId, role })
.setProtectedHeader({ alg: "HS256" })
.setIssuedAt()
.setExpirationTime("15m")
.setIssuer("https://example.com")
.setAudience("https://example.com")
.sign(secret);
}
async function verifyAccessToken(token: string) {
try {
const { payload } = await jwtVerify(token, secret, {
issuer: "https://example.com",
audience: "https://example.com",
});
return payload;
} catch {
return null;
}
}Переваги:
Недоліки — і це ті, що люди зазвичай ігнорують:
Моє правило:
Використовуй сесії коли: У тебе монолітний застосунок, тобі потрібне миттєве відкликання, ти будуєш продукт для споживачів, де безпека акаунтів критична, або твої вимоги до автентифікації можуть часто змінюватися.
Використовуй JWT коли: У тебе мікросервісна архітектура, де сервіси повинні незалежно верифікувати ідентичність, ти будуєш API-to-API комунікацію, або ти реалізуєш систему автентифікації для третіх сторін.
На практиці: Більшість застосунків повинні використовувати сесії. Аргумент «JWT більш масштабовані» стосується лише тих випадків, коли у тебе дійсно є проблема масштабування, яку сховище сесій не може вирішити — а Redis обробляє мільйони пошуків сесій на секунду. Я бачив занадто багато проєктів, що обирали JWT, бо вони звучать сучасніше, а потім будували blocklist і систему refresh token, яка складніша, ніж були б сесії.
Навіть якщо ти обереш автентифікацію на основі сесій, ти зіткнешся з JWT через OAuth, OIDC та інтеграції з третіми сторонами. Розуміння внутрішніх механізмів не підлягає обговоренню.
JWT має три частини, розділені крапками: header.payload.signature
eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.
eyJzdWIiOiJ1c2VyXzEyMyIsInJvbGUiOiJhZG1pbiIsImlhdCI6MTcwOTMxMjAwMCwiZXhwIjoxNzA5MzEyOTAwfQ.
kQ8s7nR2xC...
Header — оголошує алгоритм і тип токена:
{
"alg": "RS256",
"typ": "JWT"
}Payload — містить claims. Стандартні claims мають короткі назви:
{
"sub": "user_123", // Subject (про кого це)
"iss": "https://auth.example.com", // Issuer (хто створив)
"aud": "https://api.example.com", // Audience (хто має приймати)
"iat": 1709312000, // Issued At (мітка часу Unix)
"exp": 1709312900, // Expiration (мітка часу Unix)
"role": "admin" // Власний claim
}Signature — доводить, що токен не було підроблено. Створюється шляхом підписання закодованих header і payload секретним ключем.
HS256 (HMAC-SHA256) — симетричний. Один і той самий секрет підписує та верифікує. Просто, але кожен сервіс, якому потрібно верифікувати токени, повинен мати секрет. Якщо будь-який із них скомпрометовано, атакуючий може підробляти токени.
RS256 (RSA-SHA256) — асиметричний. Приватний ключ підписує, публічний ключ верифікує. Лише auth-сервер потребує приватного ключа. Будь-який сервіс може верифікувати публічним ключем. Якщо сервіс верифікації скомпрометовано, атакуючий може читати токени, але не підробляти їх.
import { SignJWT, jwtVerify, importPKCS8, importSPKI } from "jose";
// RS256 — використовуй це, коли кілька сервісів верифікують токени
const privateKeyPem = process.env.JWT_PRIVATE_KEY!;
const publicKeyPem = process.env.JWT_PUBLIC_KEY!;
async function signWithRS256(payload: Record<string, unknown>) {
const privateKey = await importPKCS8(privateKeyPem, "RS256");
return new SignJWT(payload)
.setProtectedHeader({ alg: "RS256", typ: "JWT" })
.setIssuedAt()
.setExpirationTime("15m")
.sign(privateKey);
}
async function verifyWithRS256(token: string) {
const publicKey = await importSPKI(publicKeyPem, "RS256");
const { payload } = await jwtVerify(token, publicKey, {
algorithms: ["RS256"], // КРИТИЧНО: завжди обмежуй алгоритми
});
return payload;
}Правило: Використовуй RS256 щоразу, коли токени перетинають межі сервісів. Використовуй HS256 тільки коли той самий сервіс і підписує, і верифікує.
alg: noneЦе найвідоміша вразливість JWT, і вона бентежно проста. Деякі бібліотеки JWT раніше:
alg з headeralg: "none", повністю пропускали верифікацію підписуАтакуючий міг взяти валідний JWT, змінити payload (наприклад, встановити "role": "admin"), встановити alg на "none", видалити підпис і надіслати його. Сервер приймав.
// ВРАЗЛИВИЙ — ніколи так не роби
function verifyJwt(token: string) {
const [headerB64, payloadB64, signature] = token.split(".");
const header = JSON.parse(atob(headerB64));
if (header.alg === "none") {
// "Підпис не потрібен" — КАТАСТРОФА
return JSON.parse(atob(payloadB64));
}
// ... верифікація підпису
}Виправлення просте: завжди вказуй очікуваний алгоритм явно. Ніколи не дозволяй токену говорити тобі, як його верифікувати.
// БЕЗПЕЧНИЙ — алгоритм зашитий у коді, а не читається з токена
const { payload } = await jwtVerify(token, key, {
algorithms: ["RS256"], // Приймати лише RS256 — ігнорувати header
});Сучасні бібліотеки на кшталт jose обробляють це правильно за замовчуванням, але ти все одно повинен явно передавати опцію algorithms як глибокий захист.
Пов'язана з попередньою: якщо сервер налаштований приймати RS256, атакуючий може:
alg: "HS256"Якщо сервер читає alg з header і перемикається на верифікацію HS256, публічний ключ (який знають усі) стає спільним секретом. Підпис валідний. Атакуючий підробив токен.
Знову ж таки, виправлення те саме: ніколи не довіряй алгоритму з header токена. Завжди зашивай його в код.
Якщо ти використовуєш JWT, тобі потрібна стратегія refresh token. Надсилати довгоживучий access token — це напрошуватися на неприємності — якщо його вкрадуть, атакуючий має доступ на весь час життя токена.
Патерн:
import { randomBytes } from "crypto";
interface RefreshTokenRecord {
tokenHash: string;
userId: string;
familyId: string; // Групує пов'язані токени разом
used: boolean;
expiresAt: Date;
createdAt: Date;
}
async function issueTokenPair(userId: string) {
const familyId = randomBytes(16).toString("hex");
const accessToken = await createAccessToken(userId);
const refreshToken = randomBytes(64).toString("hex");
const refreshTokenHash = await hashToken(refreshToken);
// Зберігаємо запис refresh token
await db.refreshToken.create({
data: {
tokenHash: refreshTokenHash,
userId,
familyId,
used: false,
expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
createdAt: new Date(),
},
});
return { accessToken, refreshToken };
}Кожного разу, коли клієнт використовує refresh token для отримання нового access token, ти видаєш новий refresh token і інвалідуєш старий:
async function rotateTokens(incomingRefreshToken: string) {
const tokenHash = await hashToken(incomingRefreshToken);
const record = await db.refreshToken.findUnique({
where: { tokenHash },
});
if (!record) {
// Токен не існує — можлива крадіжка
return null;
}
if (record.expiresAt < new Date()) {
// Токен протермінований
await db.refreshToken.delete({ where: { tokenHash } });
return null;
}
if (record.used) {
// ЦЕЙ ТОКЕН ВЖЕ БУВ ВИКОРИСТАНИЙ.
// Хтось повторно його використовує — або легітимний користувач,
// або атакуючий. В будь-якому випадку, вбиваємо все сімейство.
await db.refreshToken.deleteMany({
where: { familyId: record.familyId },
});
console.error(
`Виявлено повторне використання refresh token для користувача ${record.userId}, сімейство ${record.familyId}. Усі токени сімейства інвалідовано.`,
);
return null;
}
// Позначаємо поточний токен як використаний (не видаляємо — він потрібен для виявлення повторного використання)
await db.refreshToken.update({
where: { tokenHash },
data: { used: true },
});
// Видаємо нову пару з тим самим ID сімейства
const newRefreshToken = randomBytes(64).toString("hex");
const newRefreshTokenHash = await hashToken(newRefreshToken);
await db.refreshToken.create({
data: {
tokenHash: newRefreshTokenHash,
userId: record.userId,
familyId: record.familyId, // Те саме сімейство
used: false,
expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
createdAt: new Date(),
},
});
const newAccessToken = await createAccessToken(record.userId);
return { accessToken: newAccessToken, refreshToken: newRefreshToken };
}Розглянь сценарій:
Без виявлення повторного використання, користувач просто отримує помилку. Атакуючий продовжує з токеном B. Користувач входить знову, ніколи не дізнавшись, що його акаунт було скомпрометовано.
З виявленням повторного використання та інвалідацією сімейства: коли користувач намагається використати вже використаний токен A, система виявляє повторне використання, інвалідує кожен токен у сімействі (включно з B) і примушує обох — і користувача, і атакуючого — автентифікуватися повторно. Користувач отримує запит «будь ласка, увійдіть знову» і може зрозуміти, що щось не так.
Такий підхід використовують Auth0, Okta та Auth.js. Він не ідеальний — якщо атакуючий використає токен раніше за легітимного користувача, то саме легітимний користувач стане тим, хто активує попередження про повторне використання. Але це найкраще, що ми можемо зробити з bearer-токенами.
OAuth 2.0 та OpenID Connect — це протоколи за «Увійти через Google/GitHub/Apple». Розуміння їх необхідне, навіть якщо ти використовуєш бібліотеку, тому що коли щось зламається — а воно зламається — тобі потрібно знати, що відбувається на рівні протоколу.
OAuth 2.0 — це протокол авторизації. Він відповідає: «Чи може цей застосунок отримати доступ до даних цього користувача?» Результат — access token, який надає конкретні дозволи (scopes).
OpenID Connect (OIDC) — це рівень автентифікації, побудований поверх OAuth 2.0. Він відповідає: «Хто цей користувач?» Результат — ID token (JWT), що містить інформацію про ідентичність користувача.
Коли ти робиш «Увійти через Google», ти використовуєш OIDC. Google повідомляє твоєму застосунку, хто є користувач (автентифікація). Ти також можеш запросити OAuth scopes для доступу до його календаря чи диска (авторизація).
Це потік, який ти маєш використовувати для веб-застосунків. PKCE (Proof Key for Code Exchange) спочатку був спроєктований для мобільних додатків, але тепер рекомендується для всіх клієнтів, включно з серверними застосунками.
import { randomBytes, createHash } from "crypto";
// Крок 1: Генеруємо PKCE значення та перенаправляємо користувача
function initiateOAuthFlow() {
// Code verifier: випадковий рядок з 43-128 символів
const codeVerifier = randomBytes(32).toString("base64url").slice(0, 43);
// Code challenge: SHA256-хеш verifier, закодований у base64url
const codeChallenge = createHash("sha256").update(codeVerifier).digest("base64url");
// State: випадкове значення для захисту від CSRF
const state = randomBytes(16).toString("hex");
// Зберігаємо обидва в сесії (на стороні сервера!) перед перенаправленням
// НІКОЛИ не клади code_verifier у cookie чи URL-параметр
session.codeVerifier = codeVerifier;
session.oauthState = state;
const authUrl = new URL("https://accounts.google.com/o/oauth2/v2/auth");
authUrl.searchParams.set("client_id", process.env.GOOGLE_CLIENT_ID!);
authUrl.searchParams.set("redirect_uri", "https://example.com/api/auth/callback/google");
authUrl.searchParams.set("response_type", "code");
authUrl.searchParams.set("scope", "openid email profile");
authUrl.searchParams.set("state", state);
authUrl.searchParams.set("code_challenge", codeChallenge);
authUrl.searchParams.set("code_challenge_method", "S256");
return authUrl.toString();
}// Крок 2: Обробка callback
async function handleOAuthCallback(request: Request) {
const url = new URL(request.url);
const code = url.searchParams.get("code");
const state = url.searchParams.get("state");
const error = url.searchParams.get("error");
// Перевірка помилок від провайдера
if (error) {
throw new Error(`OAuth error: ${error}`);
}
// Верифікація, що state збігається (захист від CSRF)
if (state !== session.oauthState) {
throw new Error("State mismatch — possible CSRF attack");
}
// Обмін коду авторизації на токени
const tokenResponse = await fetch("https://oauth2.googleapis.com/token", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "authorization_code",
code: code!,
redirect_uri: "https://example.com/api/auth/callback/google",
client_id: process.env.GOOGLE_CLIENT_ID!,
client_secret: process.env.GOOGLE_CLIENT_SECRET!,
code_verifier: session.codeVerifier, // PKCE: доводить, що ми ініціювали цей потік
}),
});
const tokens = await tokenResponse.json();
// tokens.access_token — для API-викликів до Google
// tokens.id_token — JWT з ідентичністю користувача (OIDC)
// tokens.refresh_token — для отримання нових access token
// Крок 3: Верифікація ID token та отримання інформації про користувача
const idTokenPayload = await verifyGoogleIdToken(tokens.id_token);
return {
googleId: idTokenPayload.sub,
email: idTokenPayload.email,
name: idTokenPayload.name,
picture: idTokenPayload.picture,
};
}Кожен OAuth/OIDC провайдер надає ці:
Параметр state запобігає CSRF-атакам на OAuth callback. Без нього:
https://yourapp.com/callback?code=ATTACKER_CODEЗ state: твій застосунок генерує випадкове значення, зберігає його в сесії та включає в URL авторизації. Коли приходить callback, ти перевіряєш, що state збігається. Атакуючий не може це підробити, бо не має доступу до сесії жертви.
Auth.js — це те, до чого я звертаюся першим у більшості проєктів Next.js. Він обробляє танець OAuth, керування сесіями, збереження в базу даних і захист від CSRF. Ось готова до продакшену конфігурація.
// src/lib/auth.ts
import NextAuth from "next-auth";
import Google from "next-auth/providers/google";
import GitHub from "next-auth/providers/github";
import Credentials from "next-auth/providers/credentials";
import { PrismaAdapter } from "@auth/prisma-adapter";
import { prisma } from "@/lib/prisma";
import { verifyPassword } from "@/lib/password";
export const { handlers, auth, signIn, signOut } = NextAuth({
adapter: PrismaAdapter(prisma),
// Використовуємо сесії в базі даних (не JWT) для кращої безпеки
session: {
strategy: "database",
maxAge: 30 * 24 * 60 * 60, // 30 днів
updateAge: 24 * 60 * 60, // Подовжувати сесію кожні 24 години
},
providers: [
Google({
clientId: process.env.GOOGLE_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
// Запит конкретних scopes
authorization: {
params: {
scope: "openid email profile",
prompt: "consent",
access_type: "offline", // Отримати refresh token
},
},
}),
GitHub({
clientId: process.env.GITHUB_CLIENT_ID!,
clientSecret: process.env.GITHUB_CLIENT_SECRET!,
}),
// Вхід через email/пароль (використовуй обережно)
Credentials({
credentials: {
email: { label: "Email", type: "email" },
password: { label: "Password", type: "password" },
},
authorize: async (credentials) => {
if (!credentials?.email || !credentials?.password) {
return null;
}
const user = await prisma.user.findUnique({
where: { email: credentials.email as string },
});
if (!user || !user.passwordHash) {
return null;
}
const isValid = await verifyPassword(credentials.password as string, user.passwordHash);
if (!isValid) {
return null;
}
return {
id: user.id,
email: user.email,
name: user.name,
image: user.image,
};
},
}),
],
callbacks: {
// Контролюємо, хто може входити
async signIn({ user, account }) {
// Блокуємо вхід для заблокованих користувачів
if (user.id) {
const dbUser = await prisma.user.findUnique({
where: { id: user.id },
select: { banned: true },
});
if (dbUser?.banned) return false;
}
return true;
},
// Додаємо власні поля до сесії
async session({ session, user }) {
if (session.user) {
session.user.id = user.id;
// Отримуємо роль з бази даних
const dbUser = await prisma.user.findUnique({
where: { id: user.id },
select: { role: true },
});
session.user.role = dbUser?.role ?? "user";
}
return session;
},
},
pages: {
signIn: "/login",
error: "/auth/error",
verifyRequest: "/auth/verify",
},
});// src/app/api/auth/[...nextauth]/route.ts
import { handlers } from "@/lib/auth";
export const { GET, POST } = handlers;// src/middleware.ts
import { auth } from "@/lib/auth";
import { NextResponse } from "next/server";
export default auth((req) => {
const isLoggedIn = !!req.auth;
const isAuthPage = req.nextUrl.pathname.startsWith("/login") || req.nextUrl.pathname.startsWith("/register");
const isProtectedRoute =
req.nextUrl.pathname.startsWith("/dashboard") ||
req.nextUrl.pathname.startsWith("/settings") ||
req.nextUrl.pathname.startsWith("/admin");
const isAdminRoute = req.nextUrl.pathname.startsWith("/admin");
// Перенаправляємо автентифікованих користувачів зі сторінок автентифікації
if (isLoggedIn && isAuthPage) {
return NextResponse.redirect(new URL("/dashboard", req.nextUrl));
}
// Перенаправляємо неавтентифікованих користувачів на сторінку входу
if (!isLoggedIn && isProtectedRoute) {
const callbackUrl = encodeURIComponent(req.nextUrl.pathname);
return NextResponse.redirect(new URL(`/login?callbackUrl=${callbackUrl}`, req.nextUrl));
}
// Перевірка ролі адміна
if (isAdminRoute && req.auth?.user?.role !== "admin") {
return NextResponse.redirect(new URL("/dashboard", req.nextUrl));
}
return NextResponse.next();
});
export const config = {
matcher: ["/dashboard/:path*", "/settings/:path*", "/admin/:path*", "/login", "/register"],
};// src/app/dashboard/page.tsx
import { auth } from "@/lib/auth";
import { redirect } from "next/navigation";
export default async function DashboardPage() {
const session = await auth();
if (!session?.user) {
redirect("/login");
}
return (
<div>
<h1>Ласкаво просимо, {session.user.name}</h1>
<p>Роль: {session.user.role}</p>
</div>
);
}"use client";
import { useSession } from "next-auth/react";
export function UserMenu() {
const { data: session, status } = useSession();
if (status === "loading") {
return <div>Завантаження...</div>;
}
if (status === "unauthenticated") {
return <a href="/login">Увійти</a>;
}
return (
<div>
<img
src={session?.user?.image ?? "/default-avatar.png"}
alt={session?.user?.name ?? "User"}
/>
<span>{session?.user?.name}</span>
</div>
);
}Passkeys — це найзначніше покращення автентифікації за останні роки. Вони стійкі до фішингу, стійкі до replay-атак і усувають цілу категорію вразливостей, пов'язаних із паролями. Якщо ти починаєш новий проєкт у 2026, ти маєш підтримувати passkeys.
Passkeys використовують криптографію з відкритим ключем, підкріплену біометрією або PIN-кодами пристрою:
Жоден спільний секрет ніколи не перетинає мережу. Нічого для фішингу, нічого для витоку, нічого для credential stuffing.
Коли passkey створюється, він прив'язується до origin (наприклад, https://example.com). Браузер використовуватиме passkey лише на тому origin, для якого його було створено. Якщо атакуючий створить сайт-клон за адресою https://exarnple.com, passkey просто не буде запропоновано. Це забезпечується браузером, а не пильністю користувача.
Це принципово відрізняється від паролів, де користувачі постійно вводять свої облікові дані на фішингових сайтах, бо сторінка виглядає правильно.
SimpleWebAuthn — це бібліотека, яку я рекомендую. Вона правильно обробляє протокол WebAuthn і має добрі типи TypeScript.
// Серверна сторона: Реєстрація
import { generateRegistrationOptions, verifyRegistrationResponse } from "@simplewebauthn/server";
import type { GenerateRegistrationOptionsOpts, VerifiedRegistrationResponse } from "@simplewebauthn/server";
const rpName = "example.com";
const rpID = "example.com";
const origin = "https://example.com";
async function startRegistration(userId: string, userEmail: string) {
// Отримуємо існуючі passkeys користувача для виключення
const existingCredentials = await db.credential.findMany({
where: { userId },
select: { credentialId: true, transports: true },
});
const options: GenerateRegistrationOptionsOpts = {
rpName,
rpID,
userID: new TextEncoder().encode(userId),
userName: userEmail,
attestationType: "none", // Для більшості додатків атестація не потрібна
excludeCredentials: existingCredentials.map((cred) => ({
id: cred.credentialId,
transports: cred.transports,
})),
authenticatorSelection: {
residentKey: "preferred",
userVerification: "preferred",
},
};
const registrationOptions = await generateRegistrationOptions(options);
// Тимчасово зберігаємо challenge — він потрібен для верифікації
await redis.set(
`webauthn:challenge:${userId}`,
registrationOptions.challenge,
"EX",
300, // Термін дії 5 хвилин
);
return registrationOptions;
}
async function finishRegistration(userId: string, response: unknown) {
const expectedChallenge = await redis.get(`webauthn:challenge:${userId}`);
if (!expectedChallenge) {
throw new Error("Challenge expired or not found");
}
let verification: VerifiedRegistrationResponse;
try {
verification = await verifyRegistrationResponse({
response: response as any,
expectedChallenge,
expectedOrigin: origin,
expectedRPID: rpID,
});
} catch (error) {
throw new Error(`Registration verification failed: ${error}`);
}
if (!verification.verified || !verification.registrationInfo) {
throw new Error("Registration verification failed");
}
const { credential } = verification.registrationInfo;
// Зберігаємо облікові дані в базі даних
await db.credential.create({
data: {
userId,
credentialId: credential.id,
publicKey: Buffer.from(credential.publicKey),
counter: credential.counter,
transports: credential.transports ?? [],
},
});
// Прибираємо
await redis.del(`webauthn:challenge:${userId}`);
return { verified: true };
}// Серверна сторона: Автентифікація
import { generateAuthenticationOptions, verifyAuthenticationResponse } from "@simplewebauthn/server";
async function startAuthentication(userId?: string) {
let allowCredentials;
// Якщо знаємо користувача (наприклад, він ввів email), обмежуємо до його passkeys
if (userId) {
const credentials = await db.credential.findMany({
where: { userId },
select: { credentialId: true, transports: true },
});
allowCredentials = credentials.map((cred) => ({
id: cred.credentialId,
transports: cred.transports,
}));
}
const options = await generateAuthenticationOptions({
rpID,
allowCredentials,
userVerification: "preferred",
});
// Зберігаємо challenge для верифікації
const challengeKey = userId ? `webauthn:auth:${userId}` : `webauthn:auth:${options.challenge}`;
await redis.set(challengeKey, options.challenge, "EX", 300);
return options;
}
async function finishAuthentication(response: any, expectedChallenge: string, userId: string) {
const credential = await db.credential.findUnique({
where: { credentialId: response.id },
});
if (!credential) {
throw new Error("Credential not found");
}
const verification = await verifyAuthenticationResponse({
response,
expectedChallenge,
expectedOrigin: origin,
expectedRPID: rpID,
credential: {
id: credential.credentialId,
publicKey: credential.publicKey,
counter: credential.counter,
transports: credential.transports,
},
});
if (!verification.verified) {
throw new Error("Authentication verification failed");
}
// ВАЖЛИВО: Оновити лічильник для запобігання replay-атакам
await db.credential.update({
where: { credentialId: response.id },
data: {
counter: verification.authenticationInfo.newCounter,
},
});
return { verified: true, userId: credential.userId };
}// Клієнтська сторона: Реєстрація
import { startRegistration as webAuthnRegister } from "@simplewebauthn/browser";
async function registerPasskey() {
// Отримуємо опції з сервера
const optionsResponse = await fetch("/api/auth/webauthn/register", {
method: "POST",
});
const options = await optionsResponse.json();
try {
// Це активує інтерфейс passkey браузера (біометричний запит)
const credential = await webAuthnRegister(options);
// Надсилаємо облікові дані на сервер для верифікації
const verifyResponse = await fetch("/api/auth/webauthn/register/verify", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(credential),
});
const result = await verifyResponse.json();
if (result.verified) {
console.log("Passkey успішно зареєстровано!");
}
} catch (error) {
if ((error as Error).name === "NotAllowedError") {
console.log("Користувач скасував реєстрацію passkey");
}
}
}Два терміни, з якими ти зіткнешся:
attestationType: "none".Навіть із passkeys ти зіткнешся зі сценаріями, де потрібен MFA через TOTP — passkeys як другий фактор разом із паролями, або підтримка користувачів, чиї пристрої не підтримують passkeys.
TOTP — це протокол за Google Authenticator, Authy та 1Password. Він працює так:
import { createHmac, randomBytes } from "crypto";
// Генерація TOTP-секрету для користувача
function generateTOTPSecret(): string {
const buffer = randomBytes(20);
return base32Encode(buffer);
}
function base32Encode(buffer: Buffer): string {
const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
let result = "";
let bits = 0;
let value = 0;
for (const byte of buffer) {
value = (value << 8) | byte;
bits += 8;
while (bits >= 5) {
result += alphabet[(value >>> (bits - 5)) & 0x1f];
bits -= 5;
}
}
if (bits > 0) {
result += alphabet[(value << (5 - bits)) & 0x1f];
}
return result;
}
// Генерація TOTP URI для QR-коду
function generateTOTPUri(secret: string, userEmail: string, issuer: string = "example.com"): string {
const encodedIssuer = encodeURIComponent(issuer);
const encodedEmail = encodeURIComponent(userEmail);
return `otpauth://totp/${encodedIssuer}:${encodedEmail}?secret=${secret}&issuer=${encodedIssuer}&algorithm=SHA1&digits=6&period=30`;
}// Верифікація TOTP-коду
function verifyTOTP(secret: string, code: string, window: number = 1): boolean {
const secretBuffer = base32Decode(secret);
const now = Math.floor(Date.now() / 1000);
// Перевіряємо поточний часовий крок та сусідні (допуск дрейфу годинника)
for (let i = -window; i <= window; i++) {
const timeStep = Math.floor(now / 30) + i;
const expectedCode = generateTOTPCode(secretBuffer, timeStep);
// Порівняння за постійний час для запобігання timing-атакам
if (timingSafeEqual(code, expectedCode)) {
return true;
}
}
return false;
}
function generateTOTPCode(secret: Buffer, timeStep: number): string {
// Перетворюємо часовий крок у 8-байтовий big-endian буфер
const timeBuffer = Buffer.alloc(8);
timeBuffer.writeBigInt64BE(BigInt(timeStep));
// HMAC-SHA1
const hmac = createHmac("sha1", secret).update(timeBuffer).digest();
// Динамічне усічення
const offset = hmac[hmac.length - 1] & 0x0f;
const code =
((hmac[offset] & 0x7f) << 24) |
((hmac[offset + 1] & 0xff) << 16) |
((hmac[offset + 2] & 0xff) << 8) |
(hmac[offset + 3] & 0xff);
return (code % 1_000_000).toString().padStart(6, "0");
}
function timingSafeEqual(a: string, b: string): boolean {
if (a.length !== b.length) return false;
const bufA = Buffer.from(a);
const bufB = Buffer.from(b);
return createHmac("sha256", "key").update(bufA).digest().equals(createHmac("sha256", "key").update(bufB).digest());
}Користувачі втрачають свої телефони. Завжди генеруй резервні коди під час налаштування MFA:
import { randomBytes, createHash } from "crypto";
function generateBackupCodes(count: number = 10): string[] {
return Array.from(
{ length: count },
() => randomBytes(4).toString("hex").toUpperCase(), // 8-символьні hex-коди
);
}
async function storeBackupCodes(userId: string, codes: string[]) {
// Хешуємо коди перед зберіганням — поводимося з ними як із паролями
const hashedCodes = codes.map((code) => createHash("sha256").update(code).digest("hex"));
await db.backupCode.createMany({
data: hashedCodes.map((hash) => ({
userId,
codeHash: hash,
used: false,
})),
});
// Повертаємо відкриті коди ОДИН РАЗ для збереження користувачем
// Після цього у нас лише хеші
return codes;
}
async function verifyBackupCode(userId: string, code: string): Promise<boolean> {
const codeHash = createHash("sha256").update(code.toUpperCase().replace(/\s/g, "")).digest("hex");
const backupCode = await db.backupCode.findFirst({
where: {
userId,
codeHash,
used: false,
},
});
if (!backupCode) return false;
// Позначаємо як використаний — кожен резервний код працює рівно один раз
await db.backupCode.update({
where: { id: backupCode.id },
data: { used: true, usedAt: new Date() },
});
return true;
}Відновлення MFA — це частина, яку більшість посібників пропускає і більшість реальних застосунків провалює. Ось що я реалізовую:
Період очікування критичний. Якщо атакуючий скомпрометував email користувача, ти не хочеш дозволяти їм вимикати MFA миттєво. 24-годинна затримка дає легітимному користувачу час помітити лист і втрутитися.
async function initiateAccountRecovery(email: string) {
const user = await db.user.findUnique({ where: { email } });
if (!user) {
// Не розкривай, чи існує акаунт
return { message: "If that email exists, we've sent recovery instructions." };
}
const recoveryToken = randomBytes(32).toString("hex");
const tokenHash = createHash("sha256").update(recoveryToken).digest("hex");
await db.recoveryRequest.create({
data: {
userId: user.id,
tokenHash,
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000), // 24 години
status: "pending",
},
});
// Надсилаємо email із посиланням для відновлення
await sendEmail(email, {
subject: "Account Recovery Request",
body: `
Було зроблено запит на вимкнення MFA вашого акаунту.
Якщо це були ви, натисніть посилання нижче через 24 години: ...
Якщо це були НЕ ви, будь ласка, негайно змініть свій пароль.
`,
});
return { message: "If that email exists, we've sent recovery instructions." };
}Автентифікація повідомляє, хто хтось є. Авторизація повідомляє, що їм дозволено робити. Помилитися тут — це те, як ти потрапляєш у новини.
RBAC (Role-Based Access Control): Користувачі мають ролі, ролі мають дозволи. Просто, легко міркувати, працює для більшості застосунків.
// RBAC — прямолінійні перевірки ролей
type Role = "user" | "editor" | "admin" | "super_admin";
const ROLE_PERMISSIONS: Record<Role, string[]> = {
user: ["read:own_profile", "update:own_profile", "read:posts"],
editor: ["read:own_profile", "update:own_profile", "read:posts", "create:posts", "update:posts"],
admin: [
"read:own_profile",
"update:own_profile",
"read:posts",
"create:posts",
"update:posts",
"delete:posts",
"read:users",
"update:users",
],
super_admin: ["*"], // Обережно з wildcard
};
function hasPermission(role: Role, permission: string): boolean {
const permissions = ROLE_PERMISSIONS[role];
return permissions.includes("*") || permissions.includes(permission);
}
// Використання в API-маршруті
export async function DELETE(request: Request, { params }: { params: Promise<{ id: string }> }) {
const session = await auth();
if (!session?.user) {
return Response.json({ error: "Unauthorized" }, { status: 401 });
}
if (!hasPermission(session.user.role as Role, "delete:posts")) {
return Response.json({ error: "Forbidden" }, { status: 403 });
}
const { id } = await params;
await db.post.delete({ where: { id } });
return Response.json({ success: true });
}ABAC (Attribute-Based Access Control): Дозволи залежать від атрибутів користувача, ресурсу та контексту. Більш гнучкий, але складніший.
// ABAC — коли RBAC недостатньо
interface PolicyContext {
user: {
id: string;
role: string;
department: string;
clearanceLevel: number;
};
resource: {
type: string;
ownerId: string;
classification: string;
department: string;
};
action: string;
environment: {
ipAddress: string;
time: Date;
mfaVerified: boolean;
};
}
function evaluatePolicy(context: PolicyContext): boolean {
const { user, resource, action, environment } = context;
// Користувачі завжди можуть читати свої власні ресурси
if (action === "read" && resource.ownerId === user.id) {
return true;
}
// Адміни можуть читати будь-який ресурс у своєму відділі
if (action === "read" && user.role === "admin" && user.department === resource.department) {
return true;
}
// Конфіденційні ресурси вимагають MFA та мінімального рівня допуску
if (resource.classification === "confidential") {
if (!environment.mfaVerified) return false;
if (user.clearanceLevel < 3) return false;
}
// Деструктивні дії заблоковані поза робочими годинами
if (action === "delete") {
const hour = environment.time.getHours();
if (hour < 9 || hour > 17) return false;
}
return false; // Заборона за замовчуванням
}Це найважливіший принцип авторизації: перевіряй дозволи на кожному кордоні довіри, а не тільки на рівні UI.
// ПОГАНО — перевірка лише в компоненті
function DeleteButton({ post }: { post: Post }) {
const { data: session } = useSession();
// Це приховує кнопку, але не запобігає видаленню
if (session?.user?.role !== "admin") return null;
return <button onClick={() => deletePost(post.id)}>Видалити</button>;
}
// ТЕАЖ ПОГАНО — перевірка в server action, але не в API-маршруті
async function deletePostAction(postId: string) {
const session = await auth();
if (session?.user?.role !== "admin") throw new Error("Forbidden");
await db.post.delete({ where: { id: postId } });
}
// Атакуючий все одно може викликати POST /api/posts/123 напряму
// ДОБРЕ — перевірка на кожному кордоні
// 1. Приховати кнопку в UI (UX, не безпека)
// 2. Перевірити в server action (глибокий захист)
// 3. Перевірити в API-маршруті (реальний кордон безпеки)
// 4. Опціонально, перевірити в middleware (для захисту на рівні маршрутів)Перевірка UI — для досвіду користувача. Перевірка сервера — для безпеки. Ніколи не покладайся тільки на одну з них.
Middleware запускається перед кожним запитом, що збігається. Це хороше місце для грубозернистого контролю доступу:
// "Чи дозволено цьому користувачу доступ до цього розділу взагалі?"
// Дрібнозернисті перевірки ("Чи може цей користувач редагувати ЦЮ публікацію?") належать обробнику маршруту,
// бо middleware не має легкого доступу до тіла запиту чи параметрів маршруту.
export default auth((req) => {
const path = req.nextUrl.pathname;
const role = req.auth?.user?.role;
// Контроль доступу на рівні маршрутів
const routeAccess: Record<string, Role[]> = {
"/admin": ["admin", "super_admin"],
"/editor": ["editor", "admin", "super_admin"],
"/dashboard": ["user", "editor", "admin", "super_admin"],
};
for (const [route, allowedRoles] of Object.entries(routeAccess)) {
if (path.startsWith(route)) {
if (!role || !allowedRoles.includes(role as Role)) {
return NextResponse.redirect(new URL("/unauthorized", req.nextUrl));
}
}
}
return NextResponse.next();
});Це атаки, які я бачу найчастіше в реальних кодових базах. Розуміння їх необхідне.
Атака: Атакуючий створює валідну сесію на твоєму сайті, а потім обманом змушує жертву використати цей ідентифікатор сесії (наприклад, через URL-параметр або встановлення cookie через піддомен). Коли жертва входить, сесія атакуючого тепер має автентифікованого користувача.
Виправлення: Завжди регенеруй ідентифікатор сесії після успішної автентифікації. Ніколи не дозволяй ідентифікатору сесії до автентифікації переходити в сесію після автентифікації.
async function login(credentials: { email: string; password: string }, request: Request) {
const user = await verifyCredentials(credentials);
if (!user) throw new Error("Invalid credentials");
// КРИТИЧНО: Видаляємо стару сесію та створюємо нову
const oldSessionId = getSessionIdFromCookie(request);
if (oldSessionId) {
await redis.del(`session:${oldSessionId}`);
}
// Створюємо повністю нову сесію з новим ID
const newSessionId = await createSession(user.id, request);
return newSessionId;
}Атака: Користувач увійшов на твій сайт. Він відвідує зловмисну сторінку, яка робить запит до твого сайту. Оскільки cookies надсилаються автоматично, запит автентифікований.
Сучасне виправлення: SameSite cookies. Встановлення SameSite: Lax (тепер за замовчуванням у більшості браузерів) запобігає надсиланню cookies при cross-origin POST запитах, що охоплює більшість сценаріїв CSRF.
// SameSite=Lax охоплює більшість сценаріїв CSRF:
// - Блокує cookies для cross-origin POST, PUT, DELETE
// - Дозволяє cookies для cross-origin GET (навігація верхнього рівня)
// Це нормально, бо GET-запити не повинні мати побічних ефектів
cookieStore.set("session_id", sessionId, {
httpOnly: true,
secure: true,
sameSite: "lax", // Це твій захист від CSRF
maxAge: 86400,
path: "/",
});Для API, що приймають JSON, ти отримуєш додатковий захист безкоштовно: заголовок Content-Type: application/json не може бути встановлений HTML-формами, а CORS запобігає JavaScript на інших origins робити запити з власними заголовками.
Якщо тобі потрібні сильніші гарантії (наприклад, ти приймаєш надсилання форм), використовуй патерн double-submit cookie або synchronizer token. Auth.js обробляє це за тебе.
Атака: Атакуючий створює URL callback OAuth, що перенаправляє на його сайт після автентифікації: https://yourapp.com/callback?redirect_to=https://evil.com/steal-token
Якщо твій обробник callback сліпо перенаправляє на параметр redirect_to, користувач потрапляє на сайт атакуючого, потенційно з токенами в URL.
// ВРАЗЛИВИЙ
async function handleCallback(request: Request) {
const url = new URL(request.url);
const redirectTo = url.searchParams.get("redirect_to") ?? "/";
// ... автентифікація користувача ...
return Response.redirect(redirectTo); // Може бути https://evil.com!
}
// БЕЗПЕЧНИЙ
async function handleCallback(request: Request) {
const url = new URL(request.url);
const redirectTo = url.searchParams.get("redirect_to") ?? "/";
// Валідація URL перенаправлення
const safeRedirect = sanitizeRedirectUrl(redirectTo, request.url);
// ... автентифікація користувача ...
return Response.redirect(safeRedirect);
}
function sanitizeRedirectUrl(redirect: string, baseUrl: string): string {
try {
const url = new URL(redirect, baseUrl);
const base = new URL(baseUrl);
// Дозволяємо лише перенаправлення на той самий origin
if (url.origin !== base.origin) {
return "/";
}
// Дозволяємо лише перенаправлення за шляхом (без javascript: чи data: URI)
if (!url.pathname.startsWith("/")) {
return "/";
}
return url.pathname + url.search;
} catch {
return "/";
}
}Якщо ти кладеш токени в URL (не роби цього), вони витечуть через заголовок Referer, коли користувачі натискають посилання. Це спричинило реальні витоки, включно з GitHub.
Правила:
Referrer-Policy: strict-origin-when-cross-origin (або суворіший)// У middleware або layout Next.js
const headers = new Headers();
headers.set("Referrer-Policy", "strict-origin-when-cross-origin");Менш відома атака: деякі бібліотеки JWT підтримують заголовок jwk або jku, який вказує верифікатору, де знайти публічний ключ. Атакуючий може:
jwk, що вказує на його публічний ключЯкщо твоя бібліотека сліпо завантажує та використовує ключ із заголовка jwk, підпис верифікується. Виправлення: ніколи не дозволяй токену вказувати власний ключ верифікації. Завжди використовуй ключі з власної конфігурації.
Після років побудови систем автентифікації, ось що я насправді використовую сьогодні.
Це мій стек за замовчуванням для нових проєктів:
Стратегія сесій підтримується базою даних (не JWT), що дає мені миттєве відкликання та просте керування сесіями.
// Це мій типовий auth.ts для нового проєкту
import NextAuth from "next-auth";
import Google from "next-auth/providers/google";
import GitHub from "next-auth/providers/github";
import Passkey from "next-auth/providers/passkey";
import { PrismaAdapter } from "@auth/prisma-adapter";
import { prisma } from "@/lib/prisma";
export const { handlers, auth, signIn, signOut } = NextAuth({
adapter: PrismaAdapter(prisma),
session: { strategy: "database" },
providers: [
Google,
GitHub,
Passkey({
// Auth.js v5 має вбудовану підтримку passkey
// Це використовує SimpleWebAuthn під капотом
}),
],
experimental: {
enableWebAuthn: true,
},
});Я звертаюся до керованого провайдера автентифікації, коли:
Компроміси керованих провайдерів:
jose для JWT, @simplewebauthn/server для passkeys, bcrypt або argon2 для паролів. Ніколи власноруч.crypto.randomBytes(32). UUID v4 прийнятний, але має менше ентропії, ніж сирі випадкові байти.Оскільки ми про це згадали — ось як правильно хешувати паролі у 2026:
import { hash, verify } from "@node-rs/argon2";
// Argon2id — рекомендований алгоритм
// Це розумні значення за замовчуванням для веб-застосунку
async function hashPassword(password: string): Promise<string> {
return hash(password, {
memoryCost: 65536, // 64 МБ
timeCost: 3, // 3 ітерації
parallelism: 4, // 4 потоки
});
}
async function verifyPassword(password: string, hashedPassword: string): Promise<boolean> {
try {
return await verify(hashedPassword, password);
} catch {
return false;
}
}Чому argon2id замість bcrypt? Argon2id є memory-hard, що означає — атака на нього вимагає не лише потужності CPU, а й великих обсягів RAM. Це робить атаки GPU та ASIC значно дорожчими. Bcrypt все ще нормальний — він не зламаний — але argon2id є кращим вибором для нових проєктів.
Перед випуском будь-якої системи автентифікації перевір:
HttpOnly, Secure, SameSite=Lax або Strictalg)Referrer-Policy встановленоЦей список не вичерпний, але він охоплює вразливості, які я найчастіше бачив у продакшн-системах.
Автентифікація — це одна з тих сфер, де ландшафт постійно еволюціонує, але фундаментальні принципи залишаються тими самими: верифікуй ідентичність, видавай мінімально необхідні облікові дані, перевіряй дозволи на кожному кордоні та припускай компрометацію.
Найбільший зрушення у 2026 — це passkeys, що стають мейнстрімом. Підтримка браузерів універсальна, підтримка платформ (iCloud Keychain, Google Password Manager) робить UX безшовним, а властивості безпеки по-справжньому перевершують все, що ми мали раніше. Якщо ти будуєш новий застосунок, зроби passkeys основним методом входу і поводься з паролями як із запасним варіантом.
Друге за значенням зрушення — стало складніше виправдати створення власної автентифікації. Auth.js v5, Clerk та подібні рішення обробляють складні частини правильно. Єдина причина для кастомного рішення — коли твої вимоги справді не відповідають жодному існуючому рішенню — і це рідше, ніж думає більшість розробників.
Що б ти не обрав, тестуй свою автентифікацію так, як би це робив атакуючий. Спробуй повторити токени, підробити підписи, отримати доступ до маршрутів, до яких не маєш доступу, та маніпулювати URL перенаправлення. Баги, які ти знайдеш перед запуском — це ті, що не потраплять у новини.