Инвалидация токенов без чёрного списка

Библиотека @hapi/iron реализует механизм сериализации и криптографической защиты данных, известный как seal/unseal. В отличие от классических JWT, где структура токена читаема и проверяется по подписи, Iron создаёт полностью зашифрованную и подписанную строку, содержащую произвольный объект.

Основная идея заключается в том, что токен не просто подписывается, а:

  • сериализуется в JSON
  • шифруется симметричным ключом
  • защищается от подмены целостностью (HMAC)
  • упаковывается в компактную строку

Пример базовой операции:

import Iron fr om '@hapi/iron';

const password = 'super-secure-password';
const data = {
  userId: 123,
  role: 'admin',
  iat: Date.now()
};

const sealed = await Iron.seal(data, password, Iron.defaults);
const unsealed = await Iron.unseal(sealed, password, Iron.defaults);

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


Проблема инвалидирования в статeless-токенах

При использовании Iron (как и любых «самодостаточных» токенов) возникает фундаментальная проблема: после выдачи токен становится независимым от сервера.

Это означает:

  • сервер не хранит сессии
  • токен валиден до истечения срока
  • невозможно «просто удалить» токен из системы

Отсюда возникает задача: как сделать так, чтобы токен можно было инвалидировать без использования чёрного списка (blacklist).

Чёрный список в классическом виде требует хранения всех отозванных токенов, что:

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

Ограничение модели Iron

Iron не предполагает встроенного механизма ревокации. Он работает как криптографический контейнер:

  • либо секрет (password) валиден
  • либо нет
  • либо данные внутри ещё «действительны» по логике приложения

Поэтому инвалидирование реализуется не через сам токен, а через изменение условий его валидности.


Инвалидация через короткое время жизни

Самый простой способ уменьшить необходимость отзыва токенов — ограничить их TTL.

const sealed = await Iron.seal(data, password, {
  ...Iron.defaults,
  ttl: 5 * 60 * 1000 // 5 минут
});

Идея:

  • токен живёт недолго
  • компрометация имеет ограниченное окно
  • нет необходимости хранить состояние на сервере

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


Инвалидация через версию пользователя

Один из наиболее распространённых подходов — хранение версии токена в данных пользователя.

Принцип

В токен добавляется поле:

{
  userId: 123,
  tokenVersion: 7
}

В базе данных хранится:

users:
- id: 123
- token_version: 8

Проверка:

const session = await Iron.unseal(token, password, Iron.defaults);

const user = await db.users.findById(session.userId);

if (session.tokenVersion !== user.token_version) {
  throw new Error('Token invalid');
}

Инвалидация:

  • пользователь выходит из системы
  • пароль меняется
  • администратор блокирует аккаунт

Во всех случаях:

UPD ATE users SE T token_version = token_version + 1 WH ERE id = 123;

Все старые токены автоматически становятся недействительными без хранения списка.


Инвалидация через смену «эпохи» (session epoch)

Вместо версии можно использовать временную метку глобальной сессии пользователя.

{
  userId: 123,
  sessionEpoch: 1700000000000
}

В базе:

users:
- id: 123
- session_epoch: 1705000000000

При проверке:

if (session.sessionEpoch < user.session_epoch) {
  throw new Error('Session expired');
}

Применение:

  • массовый logout всех устройств
  • компрометация аккаунта
  • сброс сессий после критических изменений

Ротация ключей Iron как механизм глобальной инвалидизации

Поскольку Iron использует симметричный ключ (password), смена этого ключа приводит к автоматической инвалидизации всех токенов.

const oldPassword = 'v1-secret';
const newPassword = 'v2-secret';

После смены:

  • все старые токены невозможно расшифровать
  • сервер начинает принимать только новые

Особенность

Это «грубый» механизм:

  • подходит для emergency logout всех пользователей
  • неудобен для точечной инвалидизации

Разделение ключей через key id (kid-подобный подход)

Можно эмулировать версионирование ключей:

{
  userId: 123,
  kid: 'key-2026-01'
}

На сервере:

const keys = {
  'key-2025-12': 'old-password',
  'key-2026-01': 'new-password'
};

const password = keys[session.kid];

Инвалидация:

  • удаляется старый ключ
  • токены с ним больше не расшифровываются

Одноразовые токены через серверный счётчик

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

{
  userId: 123,
  nonce: 42
}

В базе:

users:
- id: 123
- nonce: 43

Проверка:

if (session.nonce !== user.nonce) {
  throw new Error('Token reused or outdated');
}

Сценарии:

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

Привязка токена к контексту

Дополнительный уровень — привязка токена к окружению:

{
  userId: 123,
  deviceHash: 'a1b2c3',
  ipSegment: '192.168'
}

Проверка:

if (session.deviceHash !== currentDeviceHash) {
  throw new Error('Device mismatch');
}

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


Краткоживущие access-токены без blacklist

Комбинация Iron и короткого TTL позволяет полностью отказаться от хранения состояния:

  • access token живёт 5–15 минут
  • refresh token используется для перевыпуска
  • refresh token не хранится в blacklist, а управляется версией или epoch

Пример логики:

  • access: Iron sealed, короткий TTL
  • refresh: Iron sealed, проверка tokenVersion

Глобальная модель без чёрного списка

Система инвалидирования может быть сведена к нескольким параметрам:

  • tokenVersion — точечная инвалидизация
  • sessionEpoch — массовая инвалидизация
  • kid — управление ключами
  • TTL — естественное истечение
  • ротация password — аварийный сброс

Все механизмы работают без хранения списка «плохих токенов».


Пример полной проверки Iron-токена

async function verifyToken(token, userId) {
  const session = await Iron.unseal(token, password, Iron.defaults);

  const user = await db.users.findById(userId);

  if (!user) throw new Error('User not found');

  if (session.userId !== user.id) {
    throw new Error('Invalid token payload');
  }

  if (session.tokenVersion !== user.token_version) {
    throw new Error('Token revoked');
  }

  if (session.sessionEpoch < user.session_epoch) {
    throw new Error('Session expired');
  }

  return session;
}

Сравнение подходов инвалидирования

  • TTL Минимальная сложность, слабая гибкость

  • tokenVersion Точная и контролируемая инвалидизация

  • sessionEpoch Массовая инвалидизация без перебора токенов

  • rotation password Глобальный сброс всех сессий

  • kid-based keys Управление жизненным циклом криптографических ключей


Практическая модель построения системы

В реальных системах Iron чаще всего используется не изолированно, а как часть гибридной архитектуры:

  • Iron отвечает за защиту данных токена
  • база данных отвечает за состояние пользователя
  • сервер определяет актуальность через версии и эпохи

Такой подход позволяет полностью отказаться от чёрного списка, сохраняя контроль над сессиями через минимальные, дешёвые проверки состояния.