Коротка відповідь. Access-токен – короткоживучий пропуск, який клієнт додає до кожного запиту; refresh-токен – довгоживучий, зберігається в httpOnly cookie і використовується лише для отримання нового access-токена. Два токени потрібні, щоб поєднати дві протилежні вимоги: перевіряти запити швидко (без походу в базу) і мати змогу відкликати доступ. Нижче – повний потік, робочий код і помилки, на яких сипляться кандидати.

Уяви, що є лише один довгоживучий токен. Тоді або він самодостатній (JWT) – і його неможливо відкликати до закінчення строку дії, або сервер перевіряє його в базі на кожен запит – і ти втрачаєш головну перевагу токенів: stateless-перевірку без додаткового I/O.

Розділення обов'язків знімає конфлікт:

  • Access-токен живе хвилини (типово 5–15). Його крадіжка неприємна, але вікно атаки коротке. Сервер перевіряє лише підпис – швидко і без бази.
  • Refresh-токен живе дні або тижні, але використовується рідко – лише на ендпоінті оновлення. Саме там можна дозволити собі перевірку в базі, ротацію і відкликання.

Повний потік крок за кроком

  1. Логін. Клієнт надсилає credentials. Сервер перевіряє їх, генерує пару токенів. Access повертається в тілі відповіді, refresh – у Set-Cookie з прапорцями httpOnly, Secure, SameSite.
  2. Звичайний запит. Клієнт тримає access у пам'яті процесу (змінна, стан застосунку) і додає заголовок Authorization: Bearer <token>. Сервер перевіряє підпис і строк дії – без бази.
  3. Access прострочився. Сервер відповідає 401. Клієнт викликає POST /auth/refresh; браузер сам додає refresh-cookie.
  4. Оновлення. Сервер знаходить refresh-токен у базі, перевіряє, що він не відкликаний, видає нову пару і інвалідовує старий refresh (ротація). Клієнт повторює початковий запит.
  5. Логаут. Сервер видаляє refresh-токен із бази й чистить cookie. Access доживає свої хвилини і стає марним.

Код: видача пари токенів

import jwt from 'jsonwebtoken';
import { randomUUID } from 'node:crypto';

const ACCESS_TTL = '10m';
const REFRESH_TTL_DAYS = 14;

type AccessPayload = { userId: string };

function issueAccessToken(userId: string): string {
  const payload: AccessPayload = { userId };
  return jwt.sign(payload, process.env.ACCESS_SECRET!, {
    expiresIn: ACCESS_TTL,
  });
}

async function issueRefreshToken(userId: string): Promise<string> {
  const token = randomUUID();
  const expiresAt = new Date(
    Date.now() + REFRESH_TTL_DAYS * 24 * 60 * 60 * 1000
  );
  // Зберігаємо ХЕШ, а не сам токен: витік бази не дає готових refresh-токенів
  await db.refreshToken.create({
    data: { tokenHash: sha256(token), userId, expiresAt },
  });
  return token;
}

Зверни увагу: refresh тут – не JWT, а непрозорий (opaque) ідентифікатор. Йому не потрібен підпис: сервер і так звіряє його з базою.

Код: middleware перевірки access

import type { Request, Response, NextFunction } from 'express';

export function requireAuth(req: Request, res: Response, next: NextFunction) {
  const header = req.headers.authorization;
  if (!header?.startsWith('Bearer ')) {
    return res.status(401).json({ error: 'no token' });
  }

  try {
    const payload = jwt.verify(
      header.slice('Bearer '.length),
      process.env.ACCESS_SECRET!
    ) as AccessPayload;
    req.userId = payload.userId;
    next();
  } catch {
    // І прострочений, і підроблений токен – однакова відповідь
    return res.status(401).json({ error: 'invalid token' });
  }
}

Жодного запиту в базу – в цьому й сенс короткоживучого access-токена.

Код: ендпоінт оновлення з ротацією

app.post('/auth/refresh', async (req, res) => {
  const token = req.cookies.refreshToken;
  if (!token) return res.status(401).json({ error: 'no refresh token' });

  const stored = await db.refreshToken.findUnique({
    where: { tokenHash: sha256(token) },
  });

  if (!stored || stored.expiresAt < new Date()) {
    return res.status(401).json({ error: 'refresh expired' });
  }

  // Ротація: старий токен більше не дійсний
  await db.refreshToken.delete({ where: { id: stored.id } });

  const access = issueAccessToken(stored.userId);
  const refresh = await issueRefreshToken(stored.userId);

  res
    .cookie('refreshToken', refresh, {
      httpOnly: true,
      secure: true,
      sameSite: 'strict',
      path: '/auth',
      maxAge: REFRESH_TTL_DAYS * 24 * 60 * 60 * 1000,
    })
    .json({ accessToken: access });
});

Ротація дає бонус для безпеки: якщо вкрадений refresh-токен уже використано, легітимний користувач при наступному оновленні отримає 401 – і це сигнал інвалідувати всі сесії акаунта.

Типові помилки

  • Access-токен у localStorage. Будь-який XSS читає localStorage повністю. Тримай access у пам'яті, refresh – у httpOnly cookie, яку скрипт прочитати не може. Про XSS докладно пояснює MDN.
  • Refresh без ротації. Один викрадений токен = тижні доступу. Ротація звужує вікно до одного використання.
  • Довгий TTL access-токена. Година чи доба «щоб рідше рефрешити» знецінює всю схему: відкликати такий токен неможливо.
  • Зберігання refresh-токенів у чистому вигляді. База витекла – всі сесії скомпрометовані. Зберігай хеш.
  • Спільний path для refresh-cookie. Cookie з path: '/' їде з кожним запитом. Обмеж її /auth – менше поверхні для CSRF.

JWT чи opaque – коротко

Для access зазвичай JWT: перевірка без бази – головна вигода. Для refresh краще opaque-рядок: він і так перевіряється в базі, а JWT-формат лише додає спокусу «перевіряти без бази» там, де це небезпечно. Якщо на співбесіді питають «а можна все зробити сесіями?» – чесна відповідь: так, для одного класичного вебзастосунку серверні сесії простіші; токени виграють, коли клієнтів кілька (SPA, мобільний) або сервісів більше одного.

Питання, які реально ставлять на співбесіді

  1. Чому не можна просто видати один довгий JWT? – конфлікт «швидка перевірка vs відкликання» (див. початок статті).
  2. Що станеться, якщо вкрадуть access-токен? – атакуючий має доступ до закінчення TTL; тому TTL короткий, а критичні операції вимагають повторної автентифікації.
  3. Де зберігати токени в браузері й чому? – access у пам'яті, refresh у httpOnly cookie; localStorage – ні (XSS).
  4. Як розлогінити користувача з усіх пристроїв? – видалити всі його refresh-токени; access-токени доживуть лічені хвилини.
  5. Навіщо ротація refresh-токенів? – одноразовість + детекція крадіжки при повторному використанні.

Auth – одна з найчастіших тем на backend-співбесідах із Node.js, і відповідь «ну, JWT, він безпечний» одразу видає завчену поверхневість. Якщо готуєшся системно – подивись план підготовки за 30 днів; а про те, де в цій схемі місце кешу сесійних даних, є окремий розбір Redis.

Практичне завдання

Реалізуй мінімальний auth-модуль на Express або Fastify: /auth/login, /auth/refresh, /auth/logout, один захищений /me. Потім проведи власний «пентест»: спробуй скористатися простроченим access, повторно використаним refresh, cookie без Secure. Кожен сценарій, який несподівано спрацював, – твоя наступна тема для розбору.