JWT (JSON Web Tokens)

Зачем нужно

JWT — стандарт токенов для аутентификации и авторизации. Токен содержит информацию о пользователе (claims) в зашифрованном виде. Сервер не хранит сессии — он проверяет подпись токена и извлекает данные. Это делает JWT идеальным для stateless API и микросервисов.

Где используется

  • Аутентификация в SPA (React, Vue, Angular)
  • API авторизация (REST, GraphQL)
  • Микросервисы — передача identity между сервисами
  • OAuth 2.0 — access token часто реализуют как JWT
  • Single Sign-On (SSO)

Структура JWT

eyJhbGciOiJIUzI1NiJ9.eyJ1c2VySWQiOjQyLCJyb2xlIjoiYWRtaW4ifQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c

Header.Payload.Signature

Три части, разделённые точками, закодированные в Base64URL

Header (заголовок)

// Base64URL decode первой части
{
  "alg": "HS256",  // Алгоритм подписи
  "typ": "JWT"     // Тип токена
}
// Алгоритмы: HS256 (HMAC), RS256 (RSA), ES256 (ECDSA)

Payload (полезная нагрузка)

// Base64URL decode второй части
{
  // Registered claims (стандартные)
  "iss": "myapp.com",        // Издатель (issuer)
  "sub": "42",               // Субъект (subject) — ID пользователя
  "exp": 1700000000,         // Время истечения (expiration)
  "iat": 1699996400,         // Время создания (issued at)
  "nbf": 1699996400,         // Не раньше (not before)

  // Custom claims (пользовательские)
  "userId": 42,
  "role": "admin",
  "email": "anton@mail.ru"
}

Signature (подпись)

// Подпись проверяет целостность токена
HMACSHA256(
  base64UrlEncode(header) + "." + base64UrlEncode(payload),
  secret  // Секретный ключ, известный только серверу
)

// Если кто-то изменит payload — подпись не совпадёт
// Сервер отклонит токен

Access Token + Refresh Token

Access Token:
  - Короткоживущий (15 мин — 1 час)
  - Отправляется с каждым запросом
  - Содержит данные пользователя
  - При утечке — минимальный ущерб

Refresh Token:
  - Долгоживущий (7-30 дней)
  - Используется ТОЛЬКО для получения нового access token
  - Хранится в httpOnly cookie (или secure storage)
  - Можно отозвать на сервере
// Схема работы:
// 1. Логин → получаем access + refresh
// 2. Запросы к API с access token
// 3. Access истёк (401) → отправляем refresh → получаем новый access
// 4. Refresh истёк → перелогин

// Логин
async function login(email, password) {
  const response = await fetch('/api/auth/login', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ email, password }),
  });

  const { accessToken, refreshToken } = await response.json();

  // Access token — в памяти (переменная)
  setAccessToken(accessToken);

  // Refresh token — в httpOnly cookie (сервер ставит через Set-Cookie)
  // Или если API отдаёт в body — сохранить в localStorage (менее безопасно)
}

// Refresh
async function refreshAccessToken() {
  const response = await fetch('/api/auth/refresh', {
    method: 'POST',
    credentials: 'include', // Отправить httpOnly cookie с refresh token
  });

  if (!response.ok) {
    // Refresh token истёк — перенаправить на логин
    window.location.href = '/login';
    return;
  }

  const { accessToken } = await response.json();
  setAccessToken(accessToken);
  return accessToken;
}

Реализация на сервере (Node.js)

npm install jsonwebtoken bcrypt
const jwt = require('jsonwebtoken');
const bcrypt = require('bcrypt');

const ACCESS_SECRET = process.env.ACCESS_SECRET;
const REFRESH_SECRET = process.env.REFRESH_SECRET;

// Генерация токенов
function generateTokens(user) {
  const accessToken = jwt.sign(
    { userId: user.id, role: user.role },
    ACCESS_SECRET,
    { expiresIn: '15m' }
  );

  const refreshToken = jwt.sign(
    { userId: user.id },
    REFRESH_SECRET,
    { expiresIn: '7d' }
  );

  return { accessToken, refreshToken };
}

// Логин
app.post('/api/auth/login', async (req, res) => {
  const { email, password } = req.body;

  const user = await db.users.findByEmail(email);
  if (!user) return res.status(401).json({ error: 'Неверные данные' });

  const validPassword = await bcrypt.compare(password, user.passwordHash);
  if (!validPassword) return res.status(401).json({ error: 'Неверные данные' });

  const { accessToken, refreshToken } = generateTokens(user);

  // Refresh token в httpOnly cookie
  res.cookie('refreshToken', refreshToken, {
    httpOnly: true,
    secure: true,
    sameSite: 'strict',
    maxAge: 7 * 24 * 60 * 60 * 1000, // 7 дней
  });

  res.json({ accessToken });
});

// Middleware для проверки access token
function authMiddleware(req, res, next) {
  const authHeader = req.headers.authorization;
  if (!authHeader?.startsWith('Bearer ')) {
    return res.status(401).json({ error: 'Нет токена' });
  }

  const token = authHeader.split(' ')[1];

  try {
    const payload = jwt.verify(token, ACCESS_SECRET);
    req.user = payload; // { userId, role }
    next;
  } catch (err) {
    if (err.name === 'TokenExpiredError') {
      return res.status(401).json({ error: 'Токен истёк' });
    }
    return res.status(401).json({ error: 'Невалидный токен' });
  }
}

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

  try {
    const payload = jwt.verify(refreshToken, REFRESH_SECRET);
    const user = { id: payload.userId, role: 'user' }; // Из БД в реальности
    const tokens = generateTokens(user);

    res.cookie('refreshToken', tokens.refreshToken, {
      httpOnly: true, secure: true, sameSite: 'strict',
      maxAge: 7 * 24 * 60 * 60 * 1000,
    });

    res.json({ accessToken: tokens.accessToken });
  } catch {
    res.status(401).json({ error: 'Невалидный refresh token' });
  }
});

// Защищённый endpoint
app.get('/api/profile', authMiddleware, (req, res) => {
  res.json({ userId: req.user.userId, role: req.user.role });
});

Стратегии хранения токенов

Хранилище XSS CSRF Удобство
localStorage Уязвим Защищён Просто
httpOnly cookie Защищён Уязвим Средне
Память (переменная) Защищён Защищён Теряется при reload

Рекомендация: Access token — в памяти (переменная). Refresh token — в httpOnly cookie.

Частые ошибки

  1. Секрет в кодеjwt.sign(data, 'my-secret') — секрет должен быть в .env
  2. Нет exp — токен без срока действия работает вечно
  3. Хранят в localStorage — XSS-атака крадёт токен мгновенно
  4. Слишком долгий access token — 30 дней access token = нет смысла в refresh
  5. Нет отзыва refresh token — при компрометации нельзя разлогинить пользователя
  6. Чувствительные данные в payload — JWT только кодируется (Base64), не шифруется

Практика

  1. Реализовать генерацию и верификацию JWT (jsonwebtoken)
  2. Создать login endpoint, возвращающий access + refresh tokens
  3. Реализовать authMiddleware для защиты endpoints
  4. Реализовать refresh flow с httpOnly cookie
  5. Написать клиентский interceptor для автоматического refresh при 401

Связанные темы

Ресурсы