Хеширование данных, не являющихся паролями: токены, коды подтверждения

Хеширование ассоциируется прежде всего с паролями, однако тот же подход применим к другим чувствительным данным: токенам доступа, кодам подтверждения, одноразовым ключам, ссылкам для сброса пароля. В этих случаях цель аналогична — исключить возможность восстановления исходного значения даже при компрометации базы данных.

Ключевое отличие от паролей заключается в жизненном цикле и энтропии данных:

  • токены и коды часто имеют ограниченный срок действия;
  • они могут быть сгенерированы сервером, а значит их случайность контролируется;
  • длина и формат могут быть заранее определены (например, 6-значный код или UUID).

Это влияет на выбор параметров хеширования и стратегию хранения.


Когда необходимо хеширование токенов

Хеширование оправдано в следующих сценариях:

  • Ссылки для сброса пароля — токен передаётся пользователю, но в базе хранится только его хеш;
  • Email/SMS коды подтверждения — даже при утечке невозможно использовать код повторно;
  • API-токены — защищают доступ к сервису;
  • Magic links — одноразовые ссылки для входа без пароля;
  • Refresh tokens — долгоживущие токены обновления.

Принцип: если значение даёт доступ — его нельзя хранить в открытом виде.


Генерация токенов

Перед хешированием необходимо создать криптографически стойкое значение. В Node.js это делается через модуль crypto:

const crypto = require('crypto');

function generateToken() {
  return crypto.randomBytes(32).toString('hex');
}

Особенности:

  • высокая энтропия (минимум 256 бит);
  • непредсказуемость;
  • отсутствие повторяемости.

Хеширование с использованием bcrypt.js

Библиотека bcrypt.js подходит не только для паролей, но и для любых строковых данных.

const bcrypt = require('bcryptjs');

async function hashToken(token) {
  const saltRounds = 10;
  return await bcrypt.hash(token, saltRounds);
}

Результат:

  • включает соль;
  • не поддаётся обратному преобразованию;
  • уникален даже для одинаковых входных значений.

Проверка токена

Вместо сравнения строк используется функция compare:

async function verifyToken(token, hash) {
  return await bcrypt.compare(token, hash);
}

Это важно, поскольку:

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

Отличия от хеширования паролей

1. Контроль энтропии

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

2. Необходимость скорости

Для токенов иногда требуется:

  • быстрое создание;
  • массовая генерация (например, рассылка кодов).

Поэтому:

  • допустимо использовать меньшее число раундов (saltRounds);
  • баланс смещается в сторону производительности.

3. Ограниченный срок жизни

Токены часто:

  • действуют несколько минут или часов;
  • удаляются после использования.

Это снижает требования к “вечной” стойкости хеша.


Оптимальный выбор параметров

Тип данных Рекомендуемые saltRounds
Пароли 10–12
API-токены 10–12
Коды подтверждения 6–8
Одноразовые ссылки 8–10

Причина:

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

Хеширование коротких кодов

Коды подтверждения (например, 6 цифр) имеют низкую энтропию. Это создаёт риск перебора.

Пример:

const code = "123456";
const hash = await bcrypt.hash(code, 6);

Проблема:

  • всего 1 000 000 комбинаций;
  • злоумышленник может попытаться перебрать все варианты.

Меры защиты:

  • ограничение числа попыток;
  • временная блокировка;
  • привязка к пользователю или IP;
  • короткий срок действия (например, 5 минут).

Хранение и структура данных

Пример записи в базе:

{
  "userId": "123",
  "tokenHash": "$2a$10$...",
  "expiresAt": "2026-05-01T12:00:00Z"
}

Ключевые элементы:

  • tokenHash — результат bcrypt;
  • expiresAt — контроль времени жизни;
  • отсутствие исходного токена.

Одноразовые токены

После успешной проверки токен должен:

  • удаляться из базы;
  • либо помечаться как использованный.
if (await verifyToken(input, storedHash)) {
  // удалить токен
}

Причина:

  • предотвращение повторного использования;
  • защита от перехвата.

Работа с API-токенами

API-токены часто используются многократно, поэтому:

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

Проверка:

const isValid = await bcrypt.compare(apiToken, storedHash);

Дополнительно:

  • можно хранить часть токена в открытом виде (например, первые 6 символов) для идентификации;
  • полный токен никогда не сохраняется.

Проблемы масштабирования

bcrypt — медленный алгоритм по дизайну. При большом количестве операций возможны:

  • задержки при аутентификации;
  • нагрузка на CPU.

Решения:

  • кэширование результатов (ограниченно);
  • использование очередей;
  • снижение saltRounds для краткоживущих данных;
  • переход на альтернативы (например, argon2) при необходимости.

Сравнение с HMAC

Для некоторых задач вместо bcrypt применяют HMAC:

const crypto = require('crypto');

function hashWithHmac(token, secret) {
  return crypto.createHmac('sha256', secret)
               .update(token)
               .digest('hex');
}

Отличия:

bcrypt HMAC
Медленный Быстрый
Встроенная соль Требует секретного ключа
Защита от brute-force Зависит от длины токена
Подходит для паролей Часто используется для API

bcrypt предпочтителен, если:

  • есть риск перебора;
  • токен короткий;
  • нет необходимости в высокой скорости.

Комбинированный подход

В некоторых системах используется схема:

  1. Генерация токена
  2. Хеширование bcrypt
  3. Дополнительное хранение HMAC

Это даёт:

  • быстрый поиск (по HMAC);
  • безопасную проверку (через bcrypt).

Безопасность передачи токенов

Хеширование не защищает от:

  • перехвата токена при передаче;
  • утечки через логи.

Поэтому:

  • используется HTTPS;
  • токены не логируются;
  • не вставляются в URL без необходимости.

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

Хранение токена в открытом виде

// Неправильно
db.save({ token: rawToken });

Использование слабых токенов

Math.random().toString()

Сравнение через ===

if (token === storedHash) // ошибка

Отсутствие срока действия

  • токены остаются валидными бесконечно.

Практический пример: код подтверждения

const bcrypt = require('bcryptjs');

async function createCode() {
  const code = Math.floor(100000 + Math.random() * 900000).toString();
  const hash = await bcrypt.hash(code, 6);

  return { code, hash };
}

async function verifyCode(input, hash) {
  return await bcrypt.compare(input, hash);
}

Дополнительно:

  • хранится время создания;
  • ограничиваются попытки ввода.

Практический пример: токен сброса пароля

const crypto = require('crypto');
const bcrypt = require('bcryptjs');

async function createResetToken() {
  const token = crypto.randomBytes(32).toString('hex');
  const hash = await bcrypt.hash(token, 10);

  return { token, hash };
}

Поток:

  1. Генерация токена;
  2. Отправка пользователю;
  3. Сохранение хеша;
  4. Проверка при использовании;
  5. Удаление после успеха.

Контроль времени жизни

function isExpired(expiresAt) {
  return new Date() > new Date(expiresAt);
}

Без этого:

  • токены становятся постоянной уязвимостью.

Рекомендации по архитектуре

  • использовать отдельную таблицу для токенов;
  • индексировать по пользователю;
  • автоматически очищать просроченные записи;
  • логировать попытки использования;
  • ограничивать частоту генерации.

Выводы по применению bcrypt.js

bcrypt остаётся универсальным инструментом для защиты любых чувствительных строковых данных. При работе с токенами и кодами важен баланс между:

  • криптостойкостью;
  • производительностью;
  • сроком жизни данных;
  • риском перебора.

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