Перейти к содержимому
·26 мин чтения

Современная аутентификация в 2026: JWT, сессии, OAuth и Passkeys

Полная картина аутентификации: когда использовать сессии vs JWT, потоки OAuth 2.0 / OIDC, ротация refresh-токенов, passkeys (WebAuthn) и паттерны аутентификации в Next.js, которые я реально использую.

Поделиться:X / TwitterLinkedIn

Аутентификация — это та область веб-разработки, где «оно работает» никогда не бывает достаточно. Баг в вашем date picker — это раздражение. Баг в системе аутентификации — это утечка данных.

Я реализовывал аутентификацию с нуля, мигрировал между провайдерами, отлаживал инциденты с кражей токенов и разгребал последствия решений «мы починим безопасность потом». Этот пост — исчерпывающее руководство, которого мне не хватало, когда я начинал. Не просто теория — реальные компромиссы, настоящие уязвимости и паттерны, выдерживающие продакшн-нагрузку.

Мы рассмотрим полную картину: сессии, JWT, OAuth 2.0, passkeys, MFA и авторизацию. К концу вы поймёте не только как работает каждый механизм, но и когда его использовать и почему существуют альтернативы.

Сессии vs JWT: настоящие компромиссы#

Это первое решение, с которым вы столкнётесь, и в интернете полно плохих советов по этой теме. Давайте разберёмся, что действительно имеет значение.

Аутентификация на основе сессий#

Сессии — это оригинальный подход. Сервер создаёт запись сессии, хранит её где-то (база данных, Redis, память) и отдаёт клиенту непрозрачный ID сессии в cookie.

typescript
// Упрощённое создание сессии
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;
}

Преимущества реальны:

  • Мгновенный отзыв. Удаляете запись сессии — и пользователь разлогинен. Не нужно ждать истечения срока. Это важно при обнаружении подозрительной активности.
  • Видимость сессий. Вы можете показать пользователям их активные сессии («вошёл через Chrome, Windows 11, Стамбул») и позволить им отзывать отдельные.
  • Маленький размер cookie. ID сессии — обычно 64 символа. Cookie никогда не растёт.
  • Серверный контроль. Вы можете обновить данные сессии (повысить пользователя до администратора, изменить права) и это вступит в силу при следующем запросе.

Недостатки тоже реальны:

  • Обращение к базе данных при каждом запросе. Каждый аутентифицированный запрос требует поиска сессии. С Redis это занимает меньше миллисекунды, но это всё равно зависимость.
  • Горизонтальное масштабирование требует общего хранилища. Если у вас несколько серверов, всем нужен доступ к одному и тому же хранилищу сессий. Sticky sessions — хрупкий обходной путь.
  • CSRF — проблема. Поскольку cookies отправляются автоматически, нужна защита от CSRF. Cookies с SameSite в основном решают это, но нужно понимать почему.

Аутентификация на основе JWT#

JWT переворачивает модель. Вместо хранения состояния сессии на сервере вы кодируете его в подписанный токен, который хранит клиент.

typescript
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://akousa.net")
    .setAudience("https://akousa.net")
    .sign(secret);
}
 
async function verifyAccessToken(token: string) {
  try {
    const { payload } = await jwtVerify(token, secret, {
      issuer: "https://akousa.net",
      audience: "https://akousa.net",
    });
    return payload;
  } catch {
    return null;
  }
}

Преимущества:

  • Нет серверного хранилища. Токен самодостаточен. Вы проверяете подпись и читаете claims. Никакого обращения к базе данных.
  • Работает между сервисами. В микросервисной архитектуре любой сервис с публичным ключом может проверить токен. Общее хранилище сессий не нужно.
  • Stateless-масштабирование. Добавляйте серверы, не беспокоясь о привязке сессий.

Недостатки — и именно о них обычно умалчивают:

  • JWT невозможно отозвать. После выпуска он действителен до истечения срока. Если аккаунт пользователя скомпрометирован, вы не можете принудительно разлогинить. Можно построить блэклист, но тогда вы заново вводите серверное состояние и теряете главное преимущество.
  • Размер токена. JWT с несколькими claims обычно 800+ байт. Добавьте роли, права и метаданные — и вы отправляете килобайты при каждом запросе.
  • Полезная нагрузка читаема. Payload закодирован в Base64, но не зашифрован. Любой может его декодировать. Никогда не помещайте конфиденциальные данные в JWT.
  • Проблемы с рассинхронизацией часов. Если у ваших серверов разные часы (бывает), проверки истечения становятся ненадёжными.

Когда использовать каждый#

Моё правило:

Используйте сессии, когда: У вас монолитное приложение, нужен мгновенный отзыв, вы строите продукт для конечных пользователей, где безопасность аккаунтов критична, или требования к аутентификации могут часто меняться.

Используйте JWT, когда: У вас микросервисная архитектура, где сервисы должны независимо проверять идентичность, вы строите API-to-API коммуникацию, или реализуете стороннюю систему аутентификации.

На практике: Большинству приложений следует использовать сессии. Аргумент «JWT масштабируются лучше» применим только если у вас реально есть проблема масштабирования, которую не может решить хранилище сессий — а Redis обрабатывает миллионы поисков сессий в секунду. Я видел слишком много проектов, выбравших JWT потому, что они звучат современнее, а потом построивших блэклист и систему refresh-токенов, которая сложнее, чем были бы сессии.

JWT: глубокое погружение#

Даже если вы выберете сессионную аутентификацию, вы столкнётесь с JWT через OAuth, OIDC и сторонние интеграции. Понимание внутренностей обязательно.

Анатомия JWT#

JWT состоит из трёх частей, разделённых точками: header.payload.signature

eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.
eyJzdWIiOiJ1c2VyXzEyMyIsInJvbGUiOiJhZG1pbiIsImlhdCI6MTcwOTMxMjAwMCwiZXhwIjoxNzA5MzEyOTAwfQ.
kQ8s7nR2xC...

Заголовок (Header) — объявляет алгоритм и тип токена:

json
{
  "alg": "RS256",
  "typ": "JWT"
}

Полезная нагрузка (Payload) — содержит claims. Стандартные claims имеют короткие имена:

json
{
  "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) — доказывает, что токен не был подделан. Создаётся путём подписания закодированных заголовка и полезной нагрузки секретным ключом.

RS256 vs HS256: это действительно важно#

HS256 (HMAC-SHA256) — симметричный. Один и тот же секрет подписывает и проверяет. Просто, но каждый сервис, которому нужно проверять токены, должен иметь секрет. Если хоть один из них скомпрометирован, злоумышленник может подделать токены.

RS256 (RSA-SHA256) — асимметричный. Приватный ключ подписывает, публичный — проверяет. Только серверу аутентификации нужен приватный ключ. Любой сервис может проверять с помощью публичного ключа. Если сервис проверки скомпрометирован, злоумышленник может читать токены, но не подделывать их.

typescript
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 раньше:

  1. Читали поле alg из заголовка
  2. Использовали любой алгоритм, который был указан
  3. Если alg: "none", пропускали проверку подписи полностью

Злоумышленник мог взять валидный JWT, изменить payload (например, установить "role": "admin"), установить alg в "none", удалить подпись и отправить его. Сервер принимал его.

typescript
// УЯЗВИМО — никогда так не делайте
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));
  }
 
  // ... проверка подписи
}

Решение простое: всегда указывайте ожидаемый алгоритм явно. Никогда не позволяйте токену указывать, как его проверять.

typescript
// БЕЗОПАСНО — алгоритм захардкожен, не берётся из токена
const { payload } = await jwtVerify(token, key, {
  algorithms: ["RS256"], // Принимать только RS256 — игнорировать заголовок
});

Современные библиотеки вроде jose обрабатывают это правильно по умолчанию, но всё равно стоит явно передавать опцию algorithms как защиту в глубину.

Атака подмены алгоритма#

Связана с предыдущей: если сервер настроен принимать RS256, злоумышленник может:

  1. Получить публичный ключ сервера (он ведь публичный)
  2. Создать токен с alg: "HS256"
  3. Подписать его, используя публичный ключ в качестве HMAC-секрета

Если сервер читает заголовок alg и переключается на проверку HS256, публичный ключ (который все знают) становится общим секретом. Подпись валидна. Злоумышленник подделал токен.

Опять же, исправление то же: никогда не доверяйте алгоритму из заголовка токена. Всегда жёстко задавайте его.

Ротация Refresh-токенов#

Если вы используете JWT, вам нужна стратегия refresh-токенов. Отправлять долгоживущий access-токен — значит напрашиваться на неприятности: если его украдут, злоумышленник получит доступ на весь срок жизни.

Паттерн:

  • Access-токен: короткоживущий (15 минут). Используется для API-запросов.
  • Refresh-токен: долгоживущий (30 дней). Используется только для получения нового access-токена.
typescript
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-токен и инвалидируете старый:

typescript
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 };
}

Почему инвалидация семейства имеет значение#

Рассмотрим такой сценарий:

  1. Пользователь входит, получает refresh-токен A
  2. Злоумышленник крадёт refresh-токен A
  3. Злоумышленник использует A для получения новой пары (access-токен + refresh-токен B)
  4. Пользователь пытается использовать A (который у него всё ещё есть) для обновления

Без обнаружения повторного использования пользователь просто получает ошибку. Злоумышленник продолжает с токеном B. Пользователь входит снова, никогда не узнав, что его аккаунт был скомпрометирован.

С обнаружением повторного использования и инвалидацией семейства: когда пользователь пытается использовать уже использованный токен A, система обнаруживает повтор, инвалидирует каждый токен в семействе (включая B) и вынуждает обоих — пользователя и злоумышленника — аутентифицироваться заново. Пользователь получает предложение «пожалуйста, войдите снова» и может заподозрить неладное.

Этот подход используют Auth0, Okta и Auth.js. Он не идеален — если злоумышленник использует токен раньше легитимного пользователя, именно легитимный пользователь становится тем, кто срабатывает алерт повторного использования. Но это лучшее, что мы можем сделать с bearer-токенами.

OAuth 2.0 и OIDC#

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 для доступа к его календарю или диску (авторизация).

Authorization Code Flow с PKCE#

Это поток, который вы должны использовать для веб-приложений. PKCE (Proof Key for Code Exchange) изначально был разработан для мобильных приложений, но теперь рекомендуется для всех клиентов, включая серверные приложения.

typescript
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();
}
typescript
// Шаг 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 провайдер предоставляет:

  1. Эндпоинт авторизации — куда вы перенаправляете пользователя для входа и предоставления разрешений. Возвращает authorization code.
  2. Эндпоинт токенов — куда ваш сервер обменивает authorization code на access/refresh/ID-токены. Это server-to-server вызов.
  3. Эндпоинт UserInfo — где вы можете получить дополнительные данные профиля пользователя с помощью access-токена. При использовании OIDC многое из этого уже в ID-токене.

Параметр State#

Параметр state предотвращает CSRF-атаки на OAuth callback. Без него:

  1. Злоумышленник начинает OAuth-поток на своей машине, получает authorization code
  2. Злоумышленник формирует URL: https://yourapp.com/callback?code=ATTACKER_CODE
  3. Злоумышленник обманом заставляет жертву перейти по нему (ссылка в письме, скрытое изображение)
  4. Ваше приложение обменивает код злоумышленника и привязывает его Google-аккаунт к сессии жертвы

С state: ваше приложение генерирует случайное значение, сохраняет его в сессии и включает в URL авторизации. Когда приходит callback, вы проверяете совпадение state. Злоумышленник не может подделать это, потому что у него нет доступа к сессии жертвы.

Auth.js (NextAuth) с Next.js App Router#

Auth.js — это первое, к чему я обращаюсь в большинстве проектов на Next.js. Он берёт на себя OAuth-танец, управление сессиями, сохранение в базу данных и защиту от CSRF. Вот production-ready настройка.

Базовая конфигурация#

typescript
// 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",
  },
});

Обработчик маршрутов#

typescript
// src/app/api/auth/[...nextauth]/route.ts
import { handlers } from "@/lib/auth";
 
export const { GET, POST } = handlers;

Защита через Middleware#

typescript
// 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",
  ],
};

Использование сессии в серверных компонентах#

typescript
// 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>
  );
}

Использование сессии в клиентских компонентах#

typescript
"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 (WebAuthn)#

Passkeys — самое значительное улучшение аутентификации за последние годы. Они устойчивы к фишингу, к replay-атакам и полностью устраняют целую категорию уязвимостей, связанных с паролями. Если вы начинаете новый проект в 2026 году, вам стоит поддерживать passkeys.

Как работают Passkeys#

Passkeys используют криптографию с открытым ключом, подкреплённую биометрией или PIN-кодом устройства:

  1. Регистрация: Браузер генерирует пару ключей. Приватный ключ остаётся на устройстве (в защищённом анклаве, защищён биометрией). Публичный ключ отправляется на ваш сервер.
  2. Аутентификация: Сервер отправляет challenge (случайные байты). Устройство подписывает challenge приватным ключом (после биометрической верификации). Сервер проверяет подпись сохранённым публичным ключом.

Никакой общий секрет не пересекает сеть. Нечего фишить, нечему утечь, нечего подбирать.

Почему Passkeys устойчивы к фишингу#

При создании passkey привязывается к источнику (например, https://example.com). Браузер будет использовать passkey только на том точном источнике, для которого он был создан. Если злоумышленник создаёт поддельный сайт на https://exarnple.com, passkey просто не будет предложен. Это обеспечивается браузером, а не бдительностью пользователя.

Это принципиально отличается от паролей, где пользователи регулярно вводят свои учётные данные на фишинговых сайтах, потому что страница выглядит правильно.

Реализация с SimpleWebAuthn#

SimpleWebAuthn — библиотека, которую я рекомендую. Она правильно реализует протокол WebAuthn и имеет хорошие TypeScript-типы.

typescript
// Серверная сторона: Регистрация
import {
  generateRegistrationOptions,
  verifyRegistrationResponse,
} from "@simplewebauthn/server";
import type {
  GenerateRegistrationOptionsOpts,
  VerifiedRegistrationResponse,
} from "@simplewebauthn/server";
 
const rpName = "akousa.net";
const rpID = "akousa.net";
const origin = "https://akousa.net";
 
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 };
}
typescript
// Серверная сторона: Аутентификация
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 };
}
typescript
// Клиентская сторона: Регистрация
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");
    }
  }
}

Аттестация vs Утверждение (Assertion)#

Два термина, с которыми вы столкнётесь:

  • Аттестация (регистрация): Процесс создания новых учётных данных. Аутентификатор «подтверждает» свою идентичность и возможности. Для большинства приложений проверять аттестацию не нужно — установите attestationType: "none".
  • Утверждение (Assertion) (аутентификация): Процесс использования существующих учётных данных для подписания challenge. Аутентификатор «утверждает», что пользователь является тем, за кого себя выдаёт.

Реализация MFA#

Даже с passkeys вы столкнётесь со сценариями, где нужна MFA через TOTP — passkeys как второй фактор наряду с паролями, или поддержка пользователей, чьи устройства не поддерживают passkeys.

TOTP (Одноразовые пароли на основе времени)#

TOTP — это протокол, стоящий за Google Authenticator, Authy и 1Password. Он работает так:

  1. Сервер генерирует случайный секрет (в кодировке base32)
  2. Пользователь сканирует QR-код, содержащий секрет
  3. И сервер, и приложение-аутентификатор вычисляют одинаковый 6-значный код из секрета и текущего времени
  4. Коды меняются каждые 30 секунд
typescript
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 = "akousa.net"
): string {
  const encodedIssuer = encodeURIComponent(issuer);
  const encodedEmail = encodeURIComponent(userEmail);
  return `otpauth://totp/${encodedIssuer}:${encodedEmail}?secret=${secret}&issuer=${encodedIssuer}&algorithm=SHA1&digits=6&period=30`;
}
typescript
// Верификация 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:

typescript
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 — часть, которую большинство туториалов пропускает, а большинство реальных приложений делает плохо. Вот что я реализую:

  1. Основной: TOTP-код из приложения-аутентификатора
  2. Запасной: Один из 10 резервных кодов
  3. Крайний случай: Восстановление через email с 24-часовым периодом ожидания и уведомлением на другие верифицированные каналы пользователя

Период ожидания критически важен. Если злоумышленник скомпрометировал email пользователя, вы не хотите позволить ему мгновенно отключить MFA. 24-часовая задержка даёт легитимному пользователю время заметить письмо и вмешаться.

typescript
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 vs ABAC#

RBAC (Ролевое управление доступом): У пользователей есть роли, у ролей есть разрешения. Просто, легко для понимания, работает для большинства приложений.

typescript
// 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 (Управление доступом на основе атрибутов): Разрешения зависят от атрибутов пользователя, ресурса и контекста. Более гибкое, но более сложное.

typescript
// 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.

typescript
// ПЛОХО — проверка только в компоненте
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 Next.js#

Middleware выполняется перед каждым подходящим запросом. Это хорошее место для крупнозернистого контроля доступа:

typescript
// "Разрешён ли этому пользователю доступ к этому разделу вообще?"
// Мелкозернистые проверки ("Может ли этот пользователь редактировать ЭТОТ пост?") принадлежат
// обработчику маршрута, потому что 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();
});

Распространённые уязвимости#

Это атаки, которые я чаще всего встречаю в реальных кодовых базах. Понимание их необходимо.

Фиксация сессии (Session Fixation)#

Атака: Злоумышленник создаёт валидную сессию на вашем сайте, затем обманом заставляет жертву использовать этот ID сессии (например, через параметр URL или установку cookie через поддомен). Когда жертва входит в систему, сессия злоумышленника теперь имеет аутентифицированного пользователя.

Исправление: Всегда перегенерируйте ID сессии после успешной аутентификации. Никогда не позволяйте ID сессии до аутентификации переноситься в сессию после аутентификации.

typescript
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;
}

CSRF (Подделка межсайтовых запросов)#

Атака: Пользователь авторизован на вашем сайте. Он посещает вредоносную страницу, которая делает запрос к вашему сайту. Поскольку cookies отправляются автоматически, запрос аутентифицирован.

Современное решение: Cookies с SameSite. Установка SameSite: Lax (значение по умолчанию в большинстве браузеров сейчас) запрещает отправку cookies при межсайтовых POST-запросах, что покрывает большинство сценариев CSRF.

typescript
// 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#

Атака: Злоумышленник формирует OAuth callback URL, который перенаправляет на его сайт после аутентификации: https://yourapp.com/callback?redirect_to=https://evil.com/steal-token

Если ваш обработчик callback слепо перенаправляет на параметр redirect_to, пользователь оказывается на сайте злоумышленника, потенциально с токенами в URL.

typescript
// УЯЗВИМО
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 "/";
  }
}

Утечка токенов через Referrer#

Если вы помещаете токены в URL (не делайте этого), они утекут через заголовок Referer, когда пользователи переходят по ссылкам. Это вызывало реальные утечки, в том числе на GitHub.

Правила:

  • Никогда не помещайте токены в URL query-параметры для аутентификации
  • Установите Referrer-Policy: strict-origin-when-cross-origin (или строже)
  • Если вы вынуждены помещать токены в URL (например, ссылки для верификации email), делайте их одноразовыми и короткоживущими
typescript
// В вашем Next.js middleware или layout
const headers = new Headers();
headers.set("Referrer-Policy", "strict-origin-when-cross-origin");

Инъекция ключа JWT#

Менее известная атака: некоторые библиотеки JWT поддерживают заголовки jwk или jku, которые указывают верификатору, где найти публичный ключ. Злоумышленник может:

  1. Сгенерировать свою пару ключей
  2. Создать JWT со своим payload и подписать его своим приватным ключом
  3. Установить заголовок jwk с указанием на свой публичный ключ

Если ваша библиотека слепо загружает и использует ключ из заголовка jwk, подпись проходит проверку. Решение: никогда не позволяйте токену указывать собственный ключ верификации. Всегда используйте ключи из вашей собственной конфигурации.

Мой стек аутентификации в 2026#

После лет создания систем аутентификации вот что я реально использую сегодня.

Для большинства проектов: Auth.js + PostgreSQL + Passkeys#

Это мой стек по умолчанию для новых проектов:

  • Auth.js (v5) для тяжёлой работы: OAuth-провайдеры, управление сессиями, CSRF, адаптер базы данных
  • PostgreSQL с адаптером Prisma для хранения сессий и аккаунтов
  • Passkeys через SimpleWebAuthn как основной метод входа для новых пользователей
  • Email/пароль как запасной вариант для пользователей, которые не могут использовать passkeys
  • TOTP MFA как второй фактор для входа по паролю

Стратегия сессий — на основе базы данных (не JWT), что даёт мне мгновенный отзыв и простое управление сессиями.

typescript
// Вот мой типичный 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,
  },
});

Когда использовать Clerk или Auth0 вместо этого#

Я обращаюсь к управляемому провайдеру аутентификации, когда:

  • Проект нуждается в enterprise SSO (SAML, SCIM). Корректная реализация SAML — это многомесячный проект. Clerk делает это из коробки.
  • В команде нет экспертизы в безопасности. Если никто в команде не может объяснить PKCE, им не стоит строить аутентификацию с нуля.
  • Время выхода на рынок важнее стоимости. Auth.js бесплатен, но требует дней для правильной настройки. Clerk — полдня.
  • Нужны гарантии соответствия (SOC 2, HIPAA). Управляемые провайдеры берут на себя сертификацию.

Компромиссы управляемых провайдеров:

  • Стоимость: Clerk берёт за ежемесячно активного пользователя. При масштабе это накапливается.
  • Привязка к вендору: Миграция с Clerk или Auth0 болезненна. Ваша таблица пользователей на их серверах.
  • Ограничения кастомизации: Если ваш процесс аутентификации необычный, вы будете бороться с мнениями провайдера.
  • Задержка: Каждая проверка аутентификации идёт к стороннему API. С сессиями в базе данных это локальный запрос.

Чего я избегаю#

  • Своя криптография. Я использую jose для JWT, @simplewebauthn/server для passkeys, bcrypt или argon2 для паролей. Никогда ручная реализация.
  • Хранение паролей в SHA256. Используйте bcrypt (cost factor 12+) или argon2id. SHA256 слишком быстр — злоумышленник может перебирать миллиарды хешей в секунду на GPU.
  • Долгоживущие access-токены. Максимум 15 минут. Используйте ротацию refresh-токенов для длинных сессий.
  • Симметричные секреты для межсервисной верификации. Если нескольким сервисам нужно проверять токены, используйте RS256 с парой публичный/приватный ключ.
  • Кастомные ID сессий с недостаточной энтропией. Используйте crypto.randomBytes(32) минимум. UUID v4 допустим, но имеет меньше энтропии, чем сырые случайные байты.

Хеширование паролей: правильный способ#

Раз мы упомянули это — вот как правильно хешировать пароли в 2026 году:

typescript
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 лучший выбор для новых проектов.

Чек-лист безопасности#

Перед запуском любой системы аутентификации проверьте:

  • Пароли хешируются с помощью argon2id или bcrypt (cost 12+)
  • Сессии перегенерируются после входа (предотвращает фиксацию сессий)
  • Cookies имеют флаги HttpOnly, Secure, SameSite=Lax или Strict
  • JWT указывают алгоритмы явно (никогда не доверяйте заголовку alg)
  • Access-токены истекают через 15 минут или менее
  • Реализована ротация refresh-токенов с обнаружением повторного использования
  • Параметр state OAuth проверяется (защита от CSRF)
  • URL перенаправлений валидируются по allowlist
  • Rate limiting применяется к эндпоинтам входа, регистрации и сброса пароля
  • Неудачные попытки входа логируются с IP, но без паролей
  • Блокировка аккаунта после N неудачных попыток (с прогрессивными задержками, не перманентной блокировкой)
  • Токены сброса пароля одноразовые и истекают через 1 час
  • Резервные коды MFA хешируются как пароли
  • CORS настроен разрешать только известные origin
  • Установлен заголовок Referrer-Policy
  • Нет конфиденциальных данных в JWT payload (они читаемы любым)
  • Счётчик WebAuthn проверяется и обновляется (предотвращает клонирование учётных данных)

Этот список не исчерпывающий, но он покрывает уязвимости, которые я чаще всего встречал в продакшн-системах.

Подведём итоги#

Аутентификация — одна из тех областей, где ландшафт продолжает эволюционировать, но фундамент остаётся прежним: проверяйте идентичность, выдавайте минимально необходимые учётные данные, проверяйте разрешения на каждой границе и предполагайте компрометацию.

Самый значительный сдвиг в 2026 году — passkeys выходят в мейнстрим. Поддержка браузеров универсальна, поддержка платформ (iCloud Keychain, Google Password Manager) делает UX бесшовным, а свойства безопасности действительно превосходят всё, что у нас было раньше. Если вы строите новое приложение, сделайте passkeys основным методом входа и рассматривайте пароли как запасной вариант.

Второй по значимости сдвиг — собственная аутентификация стала труднее обосновать. Auth.js v5, Clerk и аналогичные решения обрабатывают сложные части правильно. Единственная причина делать кастомную реализацию — когда ваши требования действительно не подходят ни под одно существующее решение — и это встречается реже, чем думает большинство разработчиков.

Что бы вы ни выбрали, тестируйте вашу аутентификацию так, как это делал бы злоумышленник. Пробуйте воспроизводить токены, подделывать подписи, обращаться к маршрутам, к которым не должны иметь доступа, и манипулировать URL перенаправлений. Баги, найденные до запуска, — это те баги, которые не попадают в новости.

Похожие записи