Полная картина аутентификации: когда использовать сессии vs JWT, потоки OAuth 2.0 / OIDC, ротация refresh-токенов, passkeys (WebAuthn) и паттерны аутентификации в Next.js, которые я реально использую.
Аутентификация — это та область веб-разработки, где «оно работает» никогда не бывает достаточно. Баг в вашем date picker — это раздражение. Баг в системе аутентификации — это утечка данных.
Я реализовывал аутентификацию с нуля, мигрировал между провайдерами, отлаживал инциденты с кражей токенов и разгребал последствия решений «мы починим безопасность потом». Этот пост — исчерпывающее руководство, которого мне не хватало, когда я начинал. Не просто теория — реальные компромиссы, настоящие уязвимости и паттерны, выдерживающие продакшн-нагрузку.
Мы рассмотрим полную картину: сессии, JWT, OAuth 2.0, passkeys, MFA и авторизацию. К концу вы поймёте не только как работает каждый механизм, но и когда его использовать и почему существуют альтернативы.
Это первое решение, с которым вы столкнётесь, и в интернете полно плохих советов по этой теме. Давайте разберёмся, что действительно имеет значение.
Сессии — это оригинальный подход. Сервер создаёт запись сессии, хранит её где-то (база данных, Redis, память) и отдаёт клиенту непрозрачный ID сессии в 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 потому, что они звучат современнее, а потом построивших блэклист и систему refresh-токенов, которая сложнее, чем были бы сессии.
Даже если вы выберете сессионную аутентификацию, вы столкнётесь с 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 timestamp)
"exp": 1709312900, // Expiration (Unix timestamp)
"role": "admin" // Пользовательский claim
}Подпись (Signature) — доказывает, что токен не был подделан. Создаётся путём подписания закодированных заголовка и полезной нагрузки секретным ключом.
HS256 (HMAC-SHA256) — симметричный. Один и тот же секрет подписывает и проверяет. Просто, но каждый сервис, которому нужно проверять токены, должен иметь секрет. Если хоть один из них скомпрометирован, злоумышленник может подделать токены.
RS256 (RSA-SHA256) — асимметричный. Приватный ключ подписывает, публичный — проверяет. Только серверу аутентификации нужен приватный ключ. Любой сервис может проверять с помощью публичного ключа. Если сервис проверки скомпрометирован, злоумышленник может читать токены, но не подделывать их.
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 из заголовкаalg: "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 — игнорировать заголовок
});Современные библиотеки вроде jose обрабатывают это правильно по умолчанию, но всё равно стоит явно передавать опцию algorithms как защиту в глубину.
Связана с предыдущей: если сервер настроен принимать RS256, злоумышленник может:
alg: "HS256"Если сервер читает заголовок alg и переключается на проверку HS256, публичный ключ (который все знают) становится общим секретом. Подпись валидна. Злоумышленник подделал токен.
Опять же, исправление то же: никогда не доверяйте алгоритму из заголовка токена. Всегда жёстко задавайте его.
Если вы используете JWT, вам нужна стратегия refresh-токенов. Отправлять долгоживущий access-токен — значит напрашиваться на неприятности: если его украдут, злоумышленник получит доступ на весь срок жизни.
Паттерн:
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-токена
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-токен для получения нового access-токена, вы выдаёте новый refresh-токен и инвалидируете старый:
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-токена для пользователя ${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-токен, предоставляющий определённые разрешения (scopes).
OpenID Connect (OIDC) — это слой аутентификации, построенный поверх OAuth 2.0. Он отвечает: «Кто этот пользователь?» Результат — ID-токен (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-хеш верификатора, закодированный в 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");
}
// Обмениваем authorization code на токены
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-токенов
// Шаг 3: Проверяем ID-токен и извлекаем информацию о пользователе
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. Вот production-ready настройка.
// 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-токен
},
},
}),
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>Welcome, {session.user.name}</h1>
<p>Role: {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>Loading...</div>;
}
if (status === "unauthenticated") {
return <a href="/login">Sign In</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-кодом устройства:
Никакой общий секрет не пересекает сеть. Нечего фишить, нечему утечь, нечего подбирать.
При создании passkey привязывается к источнику (например, https://example.com). Браузер будет использовать passkey только на том точном источнике, для которого он был создан. Если злоумышленник создаёт поддельный сайт на 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 {
// Это запускает UI 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 (Ролевое управление доступом): У пользователей есть роли, у ролей есть разрешения. Просто, легко для понимания, работает для большинства приложений.
// 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: ["*"], // Осторожно с подстановочными символами
};
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 (Управление доступом на основе атрибутов): Разрешения зависят от атрибутов пользователя, ресурса и контекста. Более гибкое, но более сложное.
// 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)}>Delete</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();
});Это атаки, которые я чаще всего встречаю в реальных кодовых базах. Понимание их необходимо.
Атака: Злоумышленник создаёт валидную сессию на вашем сайте, затем обманом заставляет жертву использовать этот ID сессии (например, через параметр URL или установку cookie через поддомен). Когда жертва входит в систему, сессия злоумышленника теперь имеет аутентифицированного пользователя.
Исправление: Всегда перегенерируйте ID сессии после успешной аутентификации. Никогда не позволяйте ID сессии до аутентификации переноситься в сессию после аутентификации.
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 отправляются автоматически, запрос аутентифицирован.
Современное решение: Cookies с SameSite. Установка SameSite: Lax (значение по умолчанию в большинстве браузеров сейчас) запрещает отправку cookies при межсайтовых POST-запросах, что покрывает большинство сценариев CSRF.
// SameSite=Lax покрывает большинство сценариев CSRF:
// - Блокирует cookies при межсайтовых POST, PUT, DELETE
// - Разрешает cookies при межсайтовых 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 с других источников делать запросы с кастомными заголовками.
Если нужны более сильные гарантии (например, вы принимаете отправку форм), используйте паттерн двойной отправки cookie или синхронизирующий токен. Auth.js делает это за вас.
Атака: Злоумышленник формирует OAuth callback URL, который перенаправляет на его сайт после аутентификации: 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 (или строже)// В вашем Next.js middleware или layout
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), что означает: для атаки нужна не только вычислительная мощность, но и большие объёмы 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 перенаправлений. Баги, найденные до запуска, — это те баги, которые не попадают в новости.