Перейти до вмісту
·26 хв читання

Сучасна автентифікація у 2026: JWT, сесії, OAuth та Passkeys

Повний ландшафт автентифікації: коли використовувати сесії vs JWT, потоки OAuth 2.0 / OIDC, ротація refresh token, passkeys (WebAuthn) та патерни автентифікації Next.js.

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

Автентифікація — це єдина сфера веб-розробки, де «воно працює» ніколи не є достатнім. Баг у твоєму date picker — це прикрість. Баг у твоїй системі автентифікації — це витік даних.

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

Ми охопимо весь ландшафт: сесії, JWT, OAuth 2.0, passkeys, MFA та авторизацію. Наприкінці ти зрозумієш не тільки як кожен механізм працює, а й коли його використовувати і чому існують альтернативи.

Сесії vs JWT: Реальні компроміси#

Це перше рішення, з яким ти зіткнешся, і в інтернеті повно поганих порад щодо нього. Дозволь розкласти те, що насправді має значення.

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

Сесії — це оригінальний підхід. Сервер створює запис сесії, зберігає його десь (база даних, Redis, пам'ять) і надає клієнту непрозорий ідентифікатор сесії в 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. Ідентифікатор сесії зазвичай має 64 символи. Cookie ніколи не зростає.
  • Контроль на стороні сервера. Ти можеш оновити дані сесії (підвищити користувача до адміна, змінити дозволи) і це набере чинності з наступним запитом.

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

  • Звернення до бази даних на кожному запиті. Кожен автентифікований запит потребує пошуку сесії. З Redis це менше мілісекунди, але це все одно залежність.
  • Горизонтальне масштабування потребує спільного сховища. Якщо у тебе кілька серверів, всі вони потребують доступу до одного сховища сесій. Sticky sessions — це крихкий обхідний шлях.
  • CSRF є проблемою. Оскільки cookies надсилаються автоматично, тобі потрібен захист від CSRF. SameSite cookies здебільшого вирішують це, але треба розуміти чому.

Автентифікація на основі 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 масштабування. Додавай більше серверів без турботи про affinіty сесій.

Недоліки — і це ті, що люди зазвичай ігнорують:

  • Ти не можеш відкликати JWT. Раз випущений, він валідний до закінчення терміну дії. Якщо акаунт користувача скомпрометовано, ти не можеш примусово вийти. Ти можеш побудувати blocklist, але тоді ти повернув серверний стан і втратив основну перевагу.
  • Розмір токена. JWT із кількома claims зазвичай мають 800+ байтів. Додай ролі, дозволи та метадані, і ти надсилаєш кілобайти з кожним запитом.
  • Payload читабельний. Payload закодований у Base64, а не зашифрований. Будь-хто може його декодувати. Ніколи не клади чутливі дані в JWT.
  • Проблеми розсинхронізації годинників. Якщо у твоїх серверів різні годинники (таке трапляється), перевірка терміну дії стає ненадійною.

Коли що використовувати#

Моє правило:

Використовуй сесії коли: У тебе монолітний застосунок, тобі потрібне миттєве відкликання, ти будуєш продукт для споживачів, де безпека акаунтів критична, або твої вимоги до автентифікації можуть часто змінюватися.

Використовуй JWT коли: У тебе мікросервісна архітектура, де сервіси повинні незалежно верифікувати ідентичність, ти будуєш API-to-API комунікацію, або ти реалізуєш систему автентифікації для третіх сторін.

На практиці: Більшість застосунків повинні використовувати сесії. Аргумент «JWT більш масштабовані» стосується лише тих випадків, коли у тебе дійсно є проблема масштабування, яку сховище сесій не може вирішити — а Redis обробляє мільйони пошуків сесій на секунду. Я бачив занадто багато проєктів, що обирали JWT, бо вони звучать сучасніше, а потім будували blocklist і систему refresh token, яка складніша, ніж були б сесії.

Глибоке занурення в 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)
  "exp": 1709312900,       // Expiration (мітка часу Unix)
  "role": "admin"          // Власний claim
}

Signature — доводить, що токен не було підроблено. Створюється шляхом підписання закодованих header і payload секретним ключем.

RS256 vs HS256: Це справді має значення#

HS256 (HMAC-SHA256) — симетричний. Один і той самий секрет підписує та верифікує. Просто, але кожен сервіс, якому потрібно верифікувати токени, повинен мати секрет. Якщо будь-який із них скомпрометовано, атакуючий може підробляти токени.

RS256 (RSA-SHA256) — асиметричний. Приватний ключ підписує, публічний ключ верифікує. Лише auth-сервер потребує приватного ключа. Будь-який сервіс може верифікувати публічним ключем. Якщо сервіс верифікації скомпрометовано, атакуючий може читати токени, але не підробляти їх.

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 з header
  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 — ігнорувати header
});

Сучасні бібліотеки на кшталт jose обробляють це правильно за замовчуванням, але ти все одно повинен явно передавати опцію algorithms як глибокий захист.

Атака плутанини алгоритмів#

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

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

Якщо сервер читає alg з header і перемикається на верифікацію HS256, публічний ключ (який знають усі) стає спільним секретом. Підпис валідний. Атакуючий підробив токен.

Знову ж таки, виправлення те саме: ніколи не довіряй алгоритму з header токена. Завжди зашивай його в код.

Ротація Refresh Token#

Якщо ти використовуєш JWT, тобі потрібна стратегія refresh token. Надсилати довгоживучий access token — це напрошуватися на неприємності — якщо його вкрадуть, атакуючий має доступ на весь час життя токена.

Патерн:

  • Access token: короткоживучий (15 хвилин). Використовується для API-запитів.
  • Refresh token: довгоживучий (30 днів). Використовується лише для отримання нового access token.
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 token
  await db.refreshToken.create({
    data: {
      tokenHash: refreshTokenHash,
      userId,
      familyId,
      used: false,
      expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
      createdAt: new Date(),
    },
  });
 
  return { accessToken, refreshToken };
}

Ротація при кожному використанні#

Кожного разу, коли клієнт використовує refresh token для отримання нового access token, ти видаєш новий refresh token і інвалідуєш старий:

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 token для користувача ${record.userId}, сімейство ${record.familyId}. Усі токени сімейства інвалідовано.`
    );
 
    return null;
  }
 
  // Позначаємо поточний токен як використаний (не видаляємо — він потрібен для виявлення повторного використання)
  await db.refreshToken.update({
    where: { tokenHash },
    data: { used: true },
  });
 
  // Видаємо нову пару з тим самим ID сімейства
  const newRefreshToken = randomBytes(64).toString("hex");
  const newRefreshTokenHash = await hashToken(newRefreshToken);
 
  await db.refreshToken.create({
    data: {
      tokenHash: newRefreshTokenHash,
      userId: record.userId,
      familyId: record.familyId,  // Те саме сімейство
      used: false,
      expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
      createdAt: new Date(),
    },
  });
 
  const newAccessToken = await createAccessToken(record.userId);
 
  return { accessToken: newAccessToken, refreshToken: newRefreshToken };
}

Чому інвалідація сімейства має значення#

Розглянь сценарій:

  1. Користувач входить, отримує refresh token A
  2. Атакуючий краде refresh token A
  3. Атакуючий використовує A для отримання нової пари (access token + refresh token 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 token, який надає конкретні дозволи (scopes).

OpenID Connect (OIDC) — це рівень автентифікації, побудований поверх OAuth 2.0. Він відповідає: «Хто цей користувач?» Результат — ID token (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-хеш verifier, закодований у base64url
  const codeChallenge = createHash("sha256")
    .update(codeVerifier)
    .digest("base64url");
 
  // State: випадкове значення для захисту від CSRF
  const state = randomBytes(16).toString("hex");
 
  // Зберігаємо обидва в сесії (на стороні сервера!) перед перенаправленням
  // НІКОЛИ не клади code_verifier у cookie чи URL-параметр
  session.codeVerifier = codeVerifier;
  session.oauthState = state;
 
  const authUrl = new URL("https://accounts.google.com/o/oauth2/v2/auth");
  authUrl.searchParams.set("client_id", process.env.GOOGLE_CLIENT_ID!);
  authUrl.searchParams.set("redirect_uri", "https://example.com/api/auth/callback/google");
  authUrl.searchParams.set("response_type", "code");
  authUrl.searchParams.set("scope", "openid email profile");
  authUrl.searchParams.set("state", state);
  authUrl.searchParams.set("code_challenge", codeChallenge);
  authUrl.searchParams.set("code_challenge_method", "S256");
 
  return authUrl.toString();
}
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");
  }
 
  // Обмін коду авторизації на токени
  const tokenResponse = await fetch("https://oauth2.googleapis.com/token", {
    method: "POST",
    headers: { "Content-Type": "application/x-www-form-urlencoded" },
    body: new URLSearchParams({
      grant_type: "authorization_code",
      code: code!,
      redirect_uri: "https://example.com/api/auth/callback/google",
      client_id: process.env.GOOGLE_CLIENT_ID!,
      client_secret: process.env.GOOGLE_CLIENT_SECRET!,
      code_verifier: session.codeVerifier, // PKCE: доводить, що ми ініціювали цей потік
    }),
  });
 
  const tokens = await tokenResponse.json();
  // tokens.access_token — для API-викликів до Google
  // tokens.id_token — JWT з ідентичністю користувача (OIDC)
  // tokens.refresh_token — для отримання нових access token
 
  // Крок 3: Верифікація ID token та отримання інформації про користувача
  const idTokenPayload = await verifyGoogleIdToken(tokens.id_token);
 
  return {
    googleId: idTokenPayload.sub,
    email: idTokenPayload.email,
    name: idTokenPayload.name,
    picture: idTokenPayload.picture,
  };
}

Три ендпоінти#

Кожен OAuth/OIDC провайдер надає ці:

  1. Authorization endpoint — куди ти перенаправляєш користувача для входу та надання дозволів. Повертає код авторизації.
  2. Token endpoint — де твій сервер обмінює код авторизації на access/refresh/ID токени. Це server-to-server виклик.
  3. UserInfo endpoint — де ти можеш отримати додаткові дані профілю користувача за допомогою access token. З OIDC більшість цих даних уже в ID token.

Параметр State#

Параметр state запобігає CSRF-атакам на OAuth callback. Без нього:

  1. Атакуючий ініціює потік OAuth на своїй машині, отримує код авторизації
  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. Ось готова до продакшену конфігурація.

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

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 token
        },
      },
    }),
 
    GitHub({
      clientId: process.env.GITHUB_CLIENT_ID!,
      clientSecret: process.env.GITHUB_CLIENT_SECRET!,
    }),
 
    // Вхід через email/пароль (використовуй обережно)
    Credentials({
      credentials: {
        email: { label: "Email", type: "email" },
        password: { label: "Password", type: "password" },
      },
      authorize: async (credentials) => {
        if (!credentials?.email || !credentials?.password) {
          return null;
        }
 
        const user = await prisma.user.findUnique({
          where: { email: credentials.email as string },
        });
 
        if (!user || !user.passwordHash) {
          return null;
        }
 
        const isValid = await verifyPassword(
          credentials.password as string,
          user.passwordHash
        );
 
        if (!isValid) {
          return null;
        }
 
        return {
          id: user.id,
          email: user.email,
          name: user.name,
          image: user.image,
        };
      },
    }),
  ],
 
  callbacks: {
    // Контролюємо, хто може входити
    async signIn({ user, account }) {
      // Блокуємо вхід для заблокованих користувачів
      if (user.id) {
        const dbUser = await prisma.user.findUnique({
          where: { id: user.id },
          select: { banned: true },
        });
        if (dbUser?.banned) return false;
      }
      return true;
    },
 
    // Додаємо власні поля до сесії
    async session({ session, user }) {
      if (session.user) {
        session.user.id = user.id;
        // Отримуємо роль з бази даних
        const dbUser = await prisma.user.findUnique({
          where: { id: user.id },
          select: { role: true },
        });
        session.user.role = dbUser?.role ?? "user";
      }
      return session;
    },
  },
 
  pages: {
    signIn: "/login",
    error: "/auth/error",
    verifyRequest: "/auth/verify",
  },
});

Route Handler#

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

Використання сесії в Server Components#

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>Ласкаво просимо, {session.user.name}</h1>
      <p>Роль: {session.user.role}</p>
    </div>
  );
}

Використання сесії в Client Components#

typescript
"use client";
 
import { useSession } from "next-auth/react";
 
export function UserMenu() {
  const { data: session, status } = useSession();
 
  if (status === "loading") {
    return <div>Завантаження...</div>;
  }
 
  if (status === "unauthenticated") {
    return <a href="/login">Увійти</a>;
  }
 
  return (
    <div>
      <img
        src={session?.user?.image ?? "/default-avatar.png"}
        alt={session?.user?.name ?? "User"}
      />
      <span>{session?.user?.name}</span>
    </div>
  );
}

Passkeys (WebAuthn)#

Passkeys — це найзначніше покращення автентифікації за останні роки. Вони стійкі до фішингу, стійкі до replay-атак і усувають цілу категорію вразливостей, пов'язаних із паролями. Якщо ти починаєш новий проєкт у 2026, ти маєш підтримувати passkeys.

Як працюють Passkeys#

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

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

Жоден спільний секрет ніколи не перетинає мережу. Нічого для фішингу, нічого для витоку, нічого для credential stuffing.

Чому Passkeys стійкі до фішингу#

Коли passkey створюється, він прив'язується до origin (наприклад, https://example.com). Браузер використовуватиме passkey лише на тому origin, для якого його було створено. Якщо атакуючий створить сайт-клон за адресою 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 {
    // Це активує інтерфейс 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");
    }
  }
}

Attestation vs Assertion#

Два терміни, з якими ти зіткнешся:

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

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

Навіть із passkeys ти зіткнешся зі сценаріями, де потрібен MFA через TOTP — passkeys як другий фактор разом із паролями, або підтримка користувачів, чиї пристрої не підтримують passkeys.

TOTP (Time-Based One-Time Passwords)#

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 (Role-Based Access Control): Користувачі мають ролі, ролі мають дозволи. Просто, легко міркувати, працює для більшості застосунків.

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: ["*"], // Обережно з wildcard
};
 
function hasPermission(role: Role, permission: string): boolean {
  const permissions = ROLE_PERMISSIONS[role];
  return permissions.includes("*") || permissions.includes(permission);
}
 
// Використання в API-маршруті
export async function DELETE(
  request: Request,
  { params }: { params: Promise<{ id: string }> }
) {
  const session = await auth();
  if (!session?.user) {
    return Response.json({ error: "Unauthorized" }, { status: 401 });
  }
 
  if (!hasPermission(session.user.role as Role, "delete:posts")) {
    return Response.json({ error: "Forbidden" }, { status: 403 });
  }
 
  const { id } = await params;
  await db.post.delete({ where: { id } });
  return Response.json({ success: true });
}

ABAC (Attribute-Based Access Control): Дозволи залежать від атрибутів користувача, ресурсу та контексту. Більш гнучкий, але складніший.

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)}>Видалити</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#

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

Виправлення: Завжди регенеруй ідентифікатор сесії після успішної автентифікації. Ніколи не дозволяй ідентифікатору сесії до автентифікації переходити в сесію після автентифікації.

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 (Cross-Site Request Forgery)#

Атака: Користувач увійшов на твій сайт. Він відвідує зловмисну сторінку, яка робить запит до твого сайту. Оскільки cookies надсилаються автоматично, запит автентифікований.

Сучасне виправлення: SameSite cookies. Встановлення SameSite: Lax (тепер за замовчуванням у більшості браузерів) запобігає надсиланню cookies при cross-origin POST запитах, що охоплює більшість сценаріїв CSRF.

typescript
// SameSite=Lax охоплює більшість сценаріїв CSRF:
// - Блокує cookies для cross-origin POST, PUT, DELETE
// - Дозволяє cookies для cross-origin GET (навігація верхнього рівня)
//   Це нормально, бо GET-запити не повинні мати побічних ефектів
 
cookieStore.set("session_id", sessionId, {
  httpOnly: true,
  secure: true,
  sameSite: "lax",  // Це твій захист від CSRF
  maxAge: 86400,
  path: "/",
});

Для API, що приймають JSON, ти отримуєш додатковий захист безкоштовно: заголовок Content-Type: application/json не може бути встановлений HTML-формами, а CORS запобігає JavaScript на інших origins робити запити з власними заголовками.

Якщо тобі потрібні сильніші гарантії (наприклад, ти приймаєш надсилання форм), використовуй патерн double-submit cookie або synchronizer token. Auth.js обробляє це за тебе.

Open Redirects в OAuth#

Атака: Атакуючий створює URL callback OAuth, що перенаправляє на його сайт після автентифікації: 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
// У middleware або layout Next.js
const headers = new Headers();
headers.set("Referrer-Policy", "strict-origin-when-cross-origin");

JWT Key Injection#

Менш відома атака: деякі бібліотеки 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 замість цього#

Я звертаюся до керованого провайдера автентифікації, коли:

  • Проєкту потрібен корпоративний 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 token. Максимум 15 хвилин. Використовуй ротацію refresh token для довших сесій.
  • Симетричних секретів для міжсервісної верифікації. Якщо кілька сервісів потребують верифікації токенів, використовуй RS256 з парою публічний/приватний ключ.
  • Власних ідентифікаторів сесій з недостатньою ентропією. Використовуй мінімум 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, що означає — атака на нього вимагає не лише потужності CPU, а й великих обсягів RAM. Це робить атаки GPU та ASIC значно дорожчими. Bcrypt все ще нормальний — він не зламаний — але argon2id є кращим вибором для нових проєктів.

Чеклист безпеки#

Перед випуском будь-якої системи автентифікації перевір:

  • Паролі хешуються argon2id або bcrypt (cost 12+)
  • Сесії регенеруються після входу (запобігання session fixation)
  • Cookies мають HttpOnly, Secure, SameSite=Lax або Strict
  • JWT вказують алгоритми явно (ніколи не довіряй заголовку alg)
  • Access token закінчуються за 15 хвилин або менше
  • Ротація refresh token реалізована з виявленням повторного використання
  • Параметр OAuth state верифікується (захист від CSRF)
  • URL перенаправлення валідуються проти allowlist
  • Rate limiting застосований до ендпоінтів login, реєстрації та скидання пароля
  • Невдалі спроби входу логуються з IP, але без паролів
  • Блокування акаунту після N невдалих спроб (з прогресивними затримками, не перманентним блокуванням)
  • Токени скидання пароля одноразові та закінчуються через 1 годину
  • Резервні коди MFA хешуються як паролі
  • CORS налаштовано для дозволу лише відомих origins
  • Заголовок Referrer-Policy встановлено
  • Жодних чутливих даних у payload JWT (вони читабельні для будь-кого)
  • Лічильник WebAuthn верифікується та оновлюється (запобігання клонуванню облікових даних)

Цей список не вичерпний, але він охоплює вразливості, які я найчастіше бачив у продакшн-системах.

Підсумки#

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

Найбільший зрушення у 2026 — це passkeys, що стають мейнстрімом. Підтримка браузерів універсальна, підтримка платформ (iCloud Keychain, Google Password Manager) робить UX безшовним, а властивості безпеки по-справжньому перевершують все, що ми мали раніше. Якщо ти будуєш новий застосунок, зроби passkeys основним методом входу і поводься з паролями як із запасним варіантом.

Друге за значенням зрушення — стало складніше виправдати створення власної автентифікації. Auth.js v5, Clerk та подібні рішення обробляють складні частини правильно. Єдина причина для кастомного рішення — коли твої вимоги справді не відповідають жодному існуючому рішенню — і це рідше, ніж думає більшість розробників.

Що б ти не обрав, тестуй свою автентифікацію так, як би це робив атакуючий. Спробуй повторити токени, підробити підписи, отримати доступ до маршрутів, до яких не маєш доступу, та маніпулювати URL перенаправлення. Баги, які ти знайдеш перед запуском — це ті, що не потраплять у новини.

Схожі записи