Безопасная генерация jti и предотвращение replay-атак

В спецификации JWT (JSON Web Token) существует стандартное поле jti (JWT ID), предназначенное для уникальной идентификации конкретного токена. Это значение играет ключевую роль в сценариях, где требуется контроль над повторным использованием токенов и их жизненным циклом.

Поле jti представляет собой строку, которая должна быть уникальной в пределах системы. Чаще всего оно формируется как криптографически стойкий идентификатор:

  • UUID v4
  • случайная строка фиксированной длины
  • значение, полученное из криптографически безопасного генератора

Основная задача jti — позволить серверу отличать один токен от другого даже при совпадении остальных claims (sub, aud, exp и т.д.).


Replay-атаки и природа проблемы повторного использования токенов

Replay-атака возникает в момент, когда валидный JWT перехватывается и используется повторно без согласия пользователя. Поскольку JWT по своей природе является самодостаточным и не требует хранения на сервере, его повторное предъявление часто невозможно отличить от оригинального запроса.

Сценарии возникновения:

  • перехват токена в незащищённом канале
  • утечка из браузера или логов
  • повторное использование токена после выхода пользователя
  • атаки через XSS при доступе к localStorage

Особенно критичны ситуации, когда JWT используется как bearer-токен без дополнительной проверки состояния.


Роль jti в защите от повторного использования

Использование jti позволяет реализовать механизм одноразового или ограниченно повторяемого токена.

Принцип работы:

  1. При выдаче токена создаётся уникальный jti

  2. jti сохраняется на сервере (например, в Redis)

  3. При каждом запросе выполняется проверка:

    • существует ли jti
    • не был ли он уже использован или отозван
  4. После использования jti может помечаться как “spent”

Таким образом, даже при компрометации JWT повторное использование становится невозможным.


Генерация jti с использованием библиотеки jose

Библиотека jose предоставляет инструменты для создания и проверки JWT, но генерация jti обычно реализуется на уровне приложения.

Пример создания токена с уникальным идентификатором:

import { SignJWT } from 'jose';
import { randomUUID } from 'crypto';

const secret = new TextEncoder().encode('super-secret-key');

async function createToken(userId) {
  const jti = randomUUID();

  const jwt = await new SignJWT({ sub: userId })
    .setProtectedHeader({ alg: 'HS256' })
    .setIssuedAt()
    .setExpirationTime('15m')
    .setJti(jti)
    .sign(secret);

  return { jwt, jti };
}

Здесь setJti() фиксирует уникальный идентификатор внутри токена, который затем можно извлекать и проверять при валидации.


Проверка jti при верификации JWT

При обработке входящего токена выполняется не только проверка подписи и срока действия, но и контроль уникальности jti.

import { jwtVerify } from 'jose';

const usedJtiStore = new Map(); // условный пример, в реальности Redis

async function verifyToken(token) {
  const { payload } = await jwtVerify(token, secret);

  const jti = payload.jti;

  if (!jti) {
    throw new Error('Missing jti');
  }

  if (usedJtiStore.has(jti)) {
    throw new Error('Replay detected');
  }

  usedJtiStore.set(jti, true);

  return payload;
}

В реальных системах вместо Map используется Redis с TTL, чтобы автоматически очищать устаревшие записи.


Хранение jti и управление жизненным циклом

Эффективная защита от replay-атак требует корректной стратегии хранения:

Redis с TTL

  • ключ: jti:<value>
  • значение: статус использования
  • TTL = время жизни токена (exp - now)
SET jti:abc123 USED EX 900

Поведенческая модель

  • при выдаче токена jti не записывается как использованный
  • при первом успешном запросе записывается факт использования
  • при последующих запросах проверяется наличие ключа

Одноразовые токены и строгая инвалидация

В системах с повышенными требованиями безопасности применяется модель одноразовых JWT:

  • каждый токен может быть использован только один раз
  • после первого обращения jti блокируется
  • повторный запрос с тем же токеном отклоняется

Такой подход часто используется в:

  • сбросе пароля
  • подтверждении email
  • критических финансовых операциях

Связка exp и jti для защиты от перегрузки хранилища

Прямая запись всех jti без ограничения времени приводит к росту хранилища. Поэтому критически важно синхронизировать TTL записи с временем жизни токена:

  • exp определяет верхнюю границу валидности
  • TTL в Redis должен быть равен или чуть больше exp - now

Это обеспечивает автоматическую очистку без дополнительных cron-задач.


Ошибки при использовании jti

Распространённые проблемы реализации:

  • генерация jti без криптографической стойкости (Math.random)
  • отсутствие хранения состояния на сервере
  • использование jti без проверки повторного использования
  • хранение использованных идентификаторов без TTL
  • глобальное разделение jti между разными сервисами без namespace

Модель защиты с распределённой архитектурой

В микросервисной среде проверка jti требует централизованного или синхронизированного хранилища:

  • Redis Cluster
  • KeyDB
  • распределённый кеш с консистентностью

Каждый сервис:

  • извлекает jti
  • выполняет атомарную проверку (SETNX)
  • фиксирует использование при первом успехе

Атомарная операция предотвращает race condition при параллельных запросах.


Атомарная проверка через Redis SET NX

Классическая реализация:

import { createClient } from 'redis';

const redis = createClient();

async function checkJti(jti) {
  const key = `jti:${jti}`;

  const result = await redis.set(key, '1', {
    NX: true,
    EX: 900
  });

  if (!result) {
    throw new Error('Replay detected');
  }
}

Операция SET NX гарантирует, что ключ создаётся только один раз.


Связь jti с refresh-token стратегиями

В архитектурах с refresh-token jti может использоваться отдельно:

  • access token: короткий TTL, часто без хранения
  • refresh token: содержит jti, обязательно хранится в базе
  • при ротации refresh token старый jti инвалидируется

Это создаёт цепочку контроля с возможностью отзыва сессии.


Практическая модель защиты от replay-атак

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

  • проверка подписи JWT
  • контроль срока действия exp
  • уникальность jti
  • атомарная фиксация использования
  • ограничение TTL в хранилище
  • ротация refresh-токенов

Такая схема делает повторное использование токена либо невозможным, либо легко обнаруживаемым и блокируемым.