Собираем токен: три части, подпись, срок, подделка

доступ≈ 15 минут

Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.

На входе сервер сверил пароль — как это сделать, не храня самих паролей, разобрано в статье «Пароли: почему хеш, соль и стоимость». Дальше человек шлёт десятки запросов, и гонять пароль с каждым никто не станет. Сервер один раз выдаёт строку, а клиент прикладывает её к каждому запросу: «Подход» из части V держал её на телефоне в SecureStore и при ответе 401 ставил синхронизацию на паузу («401: пауза, а не уборка»). Теперь сторона сервера. Выданных строк он не помнит и свою узнаёт по подписи. Соберём такой токен сами — три части, подпись, срок — и попробуем его подделать. Подпись будет настоящей: HMAC-SHA256 из crypto.subtle, тот самый, что крутился внутри PBKDF2. А в конце тот же токен, подписанный в браузере, проверит настоящий Node.

Разделы темы:·

В части IV личность приходила кукой, и кука несла только ключ: кто за ним стоит, сервер искал в своей таблице сессий (««Кто» приходит другим каналом»). У токена такой таблицы нет. Всё, что серверу нужно знать о человеке на каждом запросе, лежит в самой строке, а рядом — доказательство, что строку выдал этот сервер. Формат описан в RFC 7519 и называется JWT, JSON Web Token; его же выдаёт API автора учебника.

Слово «токен» в этой главе — не то же, что в главах 5–6: там токен — ключ, по которому объект просят у контейнера («Собираем контейнер: разрешение вглубь, один экземпляр, токены»: сервис знает только токен, а что под ним лежит, решает сборщик). Здесь токен — подписанная строка, которую клиент предъявляет через сеть. Эти два значения не связаны: ключ контейнера не подписан, а сетевой сертификат не используется для запроса зависимостей.

Вот токен той же формы, что API автора отдаёт после входа: те же поля, только секрет учебный, а пользователь выдуман. Ни секрета, ни библиотеки у примера нет — строка и atob. Прогноз: что из токена удастся прочитать и что окажется в третьей части?

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Прочиталось всё, кроме подписи. Первая часть — заголовок: алгоритм подписи и тип токена. Вторая — нагрузка: sub — чей токен (у автора это users.id), role, iat — когда выдан и exp — до какого момента годен, оба в секундах с 1970 года. Третья — подпись: 32 байта, записанные 43 символами. Все три части закодированы base64url — это base64, в котором «+» и «/» заменены на «-» и «_», а «=» на конце выброшены, чтобы токен без экранирования ехал и в адресе, и в заголовке. Той же записью телефон сверял SHA-256 скачанного файла («hash: телефон не верит файлу на слово»).

Кодирование — не шифрование, это было сказано ещё про cookie («Пароли, токены и чужие скрипты»), и токен — тот же случай. Роль, идентификатор и срок видит каждый, через чьи руки токен прошёл: владелец телефона, прокси, записавший заголовки в журнал, скрипт на странице, сотрудник поддержки со скриншотом. Поэтому в нагрузку кладут только то, что не жалко показать: ни почты, ни телефона, ни внутренних пометок вроде «подозрительный».

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

Раз нагрузку может прочитать каждый, переписать её тоже может каждый: раскодировать, заменить "user" на "admin", закодировать обратно. Значит, третья часть должна зависеть от первых двух так, чтобы пересчитать её мог только сервер. Простой хеш не годится, и это уже встречалось в том же разделе про манифест: хеш спасает от битого файла, но не от чужого — кто подменил данные, пересчитает и хеш. Нужен хеш с секретом внутри. Это HMAC (RFC 2104): два прохода SHA-256, в каждый из которых подмешан ключ, — тот самый, что PBKDF2 из прошлой статьи повторял сотни тысяч раз. Подписываются байты заголовок.нагрузка — первые две части в base64url вместе с точкой, так велит RFC 7515.

Сервер в примере знает секрет и подписывает первые две части токена из прошлого раздела. Злоумышленник пишет себе роль admin и пробует три подписи: прежнюю, SHA-256 без ключа и HMAC секретом-заглушкой из .env.example. Прогноз: сколько байт даст HMAC, совпадёт ли он с третьей частью и какие из четырёх токенов сервер признает своими?

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера
разбор вывода
HMAC-SHA256 — 32 байта, как и простой SHA-256, и в base64url это ровно третья часть токена: никакой другой магии в подписи нет. Все три подделки отвергнуты, каждая по своей причине. Прежняя подпись посчитана для другой нагрузки. SHA-256 без ключа той же длины, но посчитать его может кто угодно. А заглушка не совпала с секретом только потому, что сервер запущен не с ней. Будь он запущен с заглушкой, третья подделка прошла бы, и выдать себе любую роль смог бы каждый, кто видел репозиторий. Поэтому заглушку секрета ловят ещё на старте («Все причины сразу»).

Что именно доказывает подпись, стоит сказать точно: от этого зависит всё остальное. Она доказывает, что заголовок и нагрузку составил тот, кто знает секрет, и с тех пор в них не поменялось ни байта. Она не доказывает, что токен предъявил его владелец. Токен и ездит в заголовке Authorization: Bearer, а bearer — это «предъявитель»: украденный токен у вора работает так же, как у хозяина. И она не доказывает, что написанное в нагрузке всё ещё правда: роль user была правдой в момент выдачи, а за месяц учётку могли удалить или роль понизить. Как сервер живёт с этим на каждом запросе — в статье «Проверка личности на каждом запросе».

Осталось сравнение. isGenuine сравнивает подписи через sameText, без выхода на первом расхождении. Для солёного хеша пароля это было страховкой: подобрать пароль под нужное начало хеша нельзя («Строка, которая помнит свою стоимость»). С подписью дело хуже. Нагрузку злоумышленник выбирает сам, а подпись к ней может подбирать по символу: сравнение с ранним выходом отвечает чуть дольше, когда начало совпало. По сети эта разница тонет в шуме, и вытаскивать её пришлось бы статистикой по множеству повторов, а защита стоит одной функции. В Node она называется timingSafeEqual, и у неё своя ловушка — её покажет прогон в конце статьи.

HMAC — не шифр и не «хеш посолиднее». Это хеш, который без секрета не посчитать, поэтому проверить такую подпись может только тот, кто умеет её поставить.

Выданных токенов сервер не хранит — в этом весь смысл. У этого есть обратная сторона: верная подпись верна всегда. HMAC от тех же байтов завтра, через месяц и через десять лет — те же 32 байта, а забыть токен сервер не может, потому что никогда его не помнил. Жизнь украденного токена ограничивает только поле exp в самой нагрузке — и то если проверка его читает.

Сравним две проверки: одна смотрит только на подпись, вторая ещё и на exp. Часы передаются параметром, и пример проверяет токен через час, через 31 день и через год после выдачи. В четвёртой строке другой токен: подписан тем же секретом, но без exp — такой выдаёт скрипт, написанный «на один раз», скажем, для соседнего сервиса. Прогноз для каждой строки: кто пустит, а кто откажет?

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Проверка одной подписи пустила всех, включая токен годовой давности: возраста для неё не существует. Проверка со сроком отказала через 31 день, через год и токену без exp, хотя подпись у него настоящая. Последняя строка важнее, чем кажется. В RFC 7519 поле exp необязательное, и библиотеки проверяют его, только если оно есть: jsonwebtoken 9.0.3, на котором стоит API автора, пропускает подписанный токен без exp и через десять лет. Срок у токенов есть, пока его не забывает ставить никто из тех, кто знает секрет. Своя проверка может требовать exp всегда, как signatureAndExpiry.

Две мелочи, на которых ошибаются. exp — секунды, а не миллисекунды, как у Date.now(): jsonwebtoken сравнивает его с Math.floor(Date.now() / 1000). И токен перестаёт действовать в ту самую секунду, что записана в exp: по RFC 7519 — «в момент или после». Часы на разных серверах немного расходятся, поэтому RFC разрешает небольшой допуск, «обычно не больше нескольких минут»; у jsonwebtoken это опция clockTolerance в секундах.

Сколько жить токену — компромисс. У автора JWT_EXPIRY по умолчанию 30d, а отдельного refresh-токена нет: через месяц телефон получит 401 и попросит войти. Украденный токен все эти 30 дней работает. Отозвать его раньше можно, только вернув серверу память: короткий токен доступа в паре с долгим refresh-токеном, который лежит в таблице и удаляется при выходе, или список отозванных токенов, который хранят до их exp. Остаётся ещё смена секрета, но она выкидывает из системы сразу всех. Удалённую учётку и пониженную роль API автора ловит иначе: middleware процедур tRPC после подписи сверяет пользователя и роль с базой, — и это тема следующей статьи главы.

Подпись отвечает на вопрос «наш ли это токен», срок — на вопрос «не слишком ли он старый». Проверка, которая задаёт только первый, годами пускает по токену, однажды утёкшему.

Подделать токен можно, и не подбирая секрет, — достаточно убедить сервер, что подпись не нужна. Заголовок сообщает, каким алгоритмом токен подписан, и соблазн велик: прочитать alg и выбрать проверку по нему. Тогда проверка «поддерживает стандарт» и не меняется, когда появится второй алгоритм. А в стандарте, в RFC 7518, есть алгоритм none — токен без подписи. RFC 8725 оставляет его для случаев, когда целостность токена уже защищена чем-то другим.

Две проверки: одна выбирает алгоритм по заголовку, другая знает свой — HS256. Кандидатов три: настоящий токен, токен с ролью admin и пустой подписью и тот же токен с none в заголовке. Прогноз: что ответит каждая проверка каждому?

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Проверка со своим алгоритмом отвергла обе подделки. Проверка по заголовку отвергла пустую подпись при HS256 — и пустила ту же пустую подпись, стоило написать в заголовке none. Злоумышленник ничего не подбирал: он дописал в токен, как его проверять, и проверка послушалась. Заголовок — такой же ввод злоумышленника, как нагрузка, только читается он раньше подписи, и верить ему нельзя точно так же. Эту атаку и соседнюю, из фолда ниже, разбирает RFC 8725 — «лучшие практики» JWT 2020 года. Правило оттуда короткое: какие алгоритмы допустимы, решает проверяющий, и каждый ключ служит ровно одному алгоритму.

Кроме HMAC есть подписи парой ключей: RS256 на RSA и ES256 — ECDSA на кривой P-256 с SHA-256. Это та самая подпись, которой в статье про push сервер доски подписывал свой JWT для службы доставки («Подписка: адрес в чужой службе»). Подписывает закрытый ключ, проверяет открытый, и открытый публикуют: так токены одного сервиса проверяют другие, не зная его секретов. У проверки по заголовку здесь вторая дыра. Ей передают ключ — открытый, строкой PEM, — а алгоритм она берёт из токена. Злоумышленник пишет в заголовок HS256 и подписывает токен HMAC-ом, взяв за секрет ту самую опубликованную строку. Проверка посчитает HMAC с тем же «секретом», и подпись сойдётся.

Node от этого страхует наполовину: createHmac с объектом открытого ключа (KeyObject типа public) бросает ERR_CRYPTO_INVALID_KEY_OBJECT_TYPE, но строку PEM принимает как обычный секрет. jsonwebtoken 9.0.3 закрывает оба случая: строку, из которой получается открытый ключ, он превращает в открытый ключ, разрешает с ним только асимметричные алгоритмы и на такую подделку отвечает invalid algorithm.

Заголовок токена пишет тот, кто токен прислал. Всё, что там сказано об алгоритме, — просьба, а не инструкция.

Всё выше считал браузер. На сервере алгоритм тот же, меняется обёртка: вместо crypto.subtle — createHmac из node:crypto, вместо atob с заменами — Buffer с кодировкой base64url, вместо самодельного сравнения — timingSafeEqual. Прогон берёт токен, подписанный в песочнице, и проверяет его вместе с подделками из этой статьи; часы и здесь отсчитаны от iat, поэтому вывод не зависит от дня запуска. Последней строкой он зовёт timingSafeEqual на подписи и на той же подписи без последнего символа. Прогноз: что ответит verify на пять токенов и чем кончится последний вызов?

прогон node 26.5.0 · 15 сентября 2026
Токен в файле подписан в песочнице этой статьи. Смотрите на две последние строки
import { createHmac, timingSafeEqual } from 'node:crypto';

// секрет и токен — те же, что в песочнице статьи; в настоящем API секрет приходит из окружения
const SECRET = 'учебный-секрет-сервера-не-для-прода';
const token =
  'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI3ZjNjMmE5ZS01YjFkLTRlOGYtYTZjMC0yZDliOGUxZjRhMzciLCJyb2xlIjoidXNlciIsImlhdCI6MTc4OTQ2MjgwMCwiZXhwIjoxNzkyMDU0ODAwfQ.e5fn1owiYza7Ui1RxZbQsvo_rMWGUhYG1aXVo9rJ3Yw';

const DAY = 24 * 60 * 60;
const decode = (part) => JSON.parse(Buffer.from(part, 'base64url').toString());
const encode = (object) => Buffer.from(JSON.stringify(object)).toString('base64url');

function verify(candidate, now) {
  const [header, payload, signature = ''] = candidate.split('.');
  let alg;
  try {
    ({ alg } = decode(header));
  } catch {
    return 'отказ — не токен';
  }
  if (alg !== 'HS256') return `отказ — алгоритм ${alg}, а ждали HS256`;

  // сравниваются строки base64url, а не байты: Buffer.from(…, 'base64url') молча пропускает
  // посторонние символы, и у одной подписи появились бы разные записи
  const expected = Buffer.from(createHmac('sha256', SECRET).update(`${header}.${payload}`).digest('base64url'));
  const received = Buffer.from(signature);
  if (received.length !== expected.length || !timingSafeEqual(received, expected)) return 'отказ — подпись не сошлась';

  const claims = decode(payload);
  if (typeof claims.exp !== 'number' || now >= claims.exp) return 'отказ — срок истёк или его нет';
  return `пустить, роль ${claims.role}`;
}

const [header, payload, signature] = token.split('.');
const { iat } = decode(payload);
const admin = encode({ ...decode(payload), role: 'admin' });

const checks = [
  ['токен из песочницы через час', token, iat + 3600],
  ['он же через 31 день', token, iat + 31 * DAY],
  ['role: admin, подпись прежняя', `${header}.${admin}.${signature}`, iat + 3600],
  ['role: admin, alg: none', `${encode({ alg: 'none', typ: 'JWT' })}.${admin}.`, iat + 3600],
  ['подпись без последнего символа', token.slice(0, -1), iat + 3600],
];
for (const [name, candidate, now] of checks) console.log(`${name}: ${verify(candidate, now)}`);

try {
  timingSafeEqual(Buffer.from(signature), Buffer.from(signature.slice(0, -1)));
} catch (err) {
  console.log('timingSafeEqual на буферах разной длины:', err.code);
}
node token.mjs

Токен из браузера сошёлся в Node байт в байт: HMAC-SHA256 — стандарт, и неважно, кто его считает. Подделки отвергнуты тем же порядком проверок, что в песочнице: алгоритм, подпись, срок. Интереснее последняя строка. timingSafeEqual сравнивает только буферы одной длины, а на разных бросает исключение с кодом ERR_CRYPTO_TIMING_SAFE_EQUAL_LENGTH. Без проверки длины перед ней обрезанная подпись получила бы не отказ, а исключение из стража — в Nest это ответ 500 и стек в журнале на каждый мусорный заголовок. Длина подписи не секрет, и сравнивать её заранее безопасно.

Комментарий в verify тоже не для порядка. Buffer.from(подпись, "base64url") молча пропускает посторонние символы: подпись с «!» или «=» на конце раскодируется в те же 32 байта. Проверка, которая сравнивает байты, признала бы все такие строки одним токеном, а список отозванных токенов, где лежат строки, пропустил бы любую из них.

В API автора подпись своими руками не считают: там jsonwebtoken 9, и подпись со сроком в verifyToken проверяет одна его строка.

tsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

jwt.verify делает то же, что наш verify, кроме одного: exp он не требует, как было сказано в разделе о сроке. Алгоритмы он выводит из ключа: для строки-секрета разрешены только HS256, HS384 и HS512. Токен с none и пустой подписью отсекается ещё раньше, отказом «jwt signature is required»; подделанная нагрузка получает «invalid signature», истёкший срок — «jwt expired». Токен из этой статьи он выдал бы символ в символ: jwt.sign с тем же секретом, теми же sub и role, сроком 30d и часами, остановленными на iat, возвращает ту же строку. Всё это проверено на 9.0.3 из apps/api. Правило RFC 8725 здесь выполняет умолчание библиотеки; явная опция { algorithms: ["HS256"] } оставляет из трёх алгоритмов один и уже не зависит от того, что решат её следующие версии.

И обратите внимание на проверку после подписи. Подпись говорит «этот токен выдали мы», но не «его выдала нынешняя версия приложения»: токен, выпущенный месяц назад, может нести поля и роли, которых сейчас нет, поэтому форму нагрузки проверяют, как любой ввод. А существует ли ещё пользователь и та ли у него роль, решает уже база.

  • JWT — три части через точку в base64url: заголовок, нагрузка и подпись. Заголовок и нагрузку прочтёт каждый, через чьи руки прошёл токен: кодирование — не шифрование, поэтому в нагрузку кладут только то, что не жалко показать.
  • Подпись — HMAC-SHA256 от байтов заголовок.нагрузка с секретом сервера, 32 байта. Хеш без ключа пересчитает кто угодно, HMAC — только тот, кто знает секрет; подпись заглушкой из .env.example пройдёт на сервере, запущенном с этой заглушкой.
  • Подпись доказывает, что токен выдан сервером и с тех пор не изменён. Ни того, что его предъявил владелец, ни того, что роль в нагрузке всё ещё правда, она не доказывает.
  • Верная подпись верна всегда, а выданных токенов сервер не помнит: украденный токен живёт до exp. В RFC 7519 exp необязателен, и jsonwebtoken пропускает токен без него, поэтому срок ставят всегда и считают в секундах, а не в миллисекундах.
  • Алгоритм задаёт проверяющий, а не заголовок токена. Проверка по заголовку пускает alg: none с пустой подписью, а при паре ключей — HMAC, подписанный опубликованным открытым ключом.
  • В Node подпись считает createHmac, а сравнивает timingSafeEqual — после проверки длины: на буферах разной длины она бросает ERR_CRYPTO_TIMING_SAFE_EQUAL_LENGTH. Токен, подписанный в браузере, сходится в Node байт в байт.

1/4Злоумышленник перехватил токен, но секрета сервера не знает. Что он может узнать из токена?

2/4В нагрузке role заменили на admin, а третью часть пересчитали как SHA-256 от новых первых двух. Что ответит проверка?

3/4Подпись у токена верная, но выдан он год назад. Почему проверка всё равно должна отказать?

4/4Проверка берёт алгоритм из поля alg заголовка и поддерживает всё, что есть в RFC 7518. Чем это опасно?

Токен на 15 минут не протухает

Токен выдают на 15 минут, но проверка пускает по нему и через год. Подпись в порядке, срок verify читает. Найдите ошибку и почините так, чтобы через 16 минут приходил отказ.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Ошибка в выдаче, а не в проверке. nowMs — миллисекунды, как у Date.now(), и exp: nowMs + TTL_SECONDS прибавляет 900 секунд к числу миллисекунд. Выходит 1789462800900, а проверка честно сравнивает его с секундами: прочитанный как секунды, такой exp наступит в 58 675 году. iat рядом при этом верный — деление на 1000 в нём не забыли, и на глаз нагрузка выглядит правдоподобно.

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Вывод: через минуту → пустить, через 14 минут → пустить, через 16 минут → отказ: срок, через год → отказ: срок.

Тест «свежий токен проходит» такую ошибку не ловит: свежий токен проходит и с ней. Ловит только проверка с часами, переведёнными вперёд, — поэтому часы и передаются параметром. jsonwebtoken тоже не спасёт: exp, записанный в нагрузку руками, jwt.sign принимает как есть, и jwt.verify через год пускает по нему так же (проверено на 9.0.3). Опция expiresIn считает секунды сама.

Ссылка из письма открывает API

Сервер выдаёт токены двух видов: при входе — для запросов к API, в письме «подтвердите адрес» — для одной ссылки. Обе делает одна функция issue. Ссылка из письма оседает в почтовом ящике, в истории браузера, в журналах почтового сервиса. Посмотрите, что с ней можно сделать, и почините так, чтобы каждый токен работал только там, для чего выдан.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Подпись у обоих токенов верная, срок не истёк, форма нагрузки одинаковая — проверке не за что их различить. Ссылка из письма даёт неделю доступа к API, а токен входа подтверждает адрес. Различие надо записать в нагрузку: для этого в JWT есть поле aud, «для кого выдан», и каждая проверка требует своё значение.

tsxлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

В Claims появляется aud: Audience, ссылка получает aud: "confirm-email", а api и confirmEmail передают в verify своё значение. Вывод: токен входа → API: 200, роль user, ссылка из письма → подтверждение: адрес подтверждён, ссылка из письма → API: 401, токен входа → подтверждение: ссылка недействительна.

Второе средство — разные секреты для разных видов токенов: тогда чужой токен не сойдётся уже по подписи. Так API автора подписывает ссылку восстановления пароля, и её секрет выведен ещё и из текущего хеша пароля; зачем — разбирает статья «Восстановление пароля и письмо, которое обязано дойти». RFC 8725 называет эту атаку подменой между видами JWT и советует проверять aud и явно указывать тип токена.

Страж пропускает любую подпись

Страж раньше проверял подпись синхронной библиотекой, потом проверку переписали на crypto.subtle. Тест «свой токен проходит» зелёный, компилятор молчит. Посмотрите, что страж делает с подделками, найдите причину и почините.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

isGenuine возвращает промис, а промис — объект, и !промис всегда false. Условие отказа не срабатывает никогда, а сама проверка подписи честно досчитывается уже после того, как страж ответил 200. Тест «свой токен проходит» такую ошибку не увидит в принципе: свой токен и должен проходить.

tsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Страж стал асинхронным, и вызовы его ждут: console.log("свой токен:", await guard(`Bearer ${token}`)) внутри async function main. Вывод: свой токен: 200, роль user, роль подменена: 401, подпись выдумана: 401.

Компилятор молчал не случайно. Условие с промисом, которое всегда истинно, разбиралось в статье про линтер («no-misused-promises»): на if (isGenuine(candidate)) и на тернарный оператор TypeScript 6.0.3 отвечает ошибкой TS2801. А на отрицание молчит — проверено с настройками песочницы. Отрицание ловит только правило линтера с проверкой типов @typescript-eslint/no-misused-promises: промис там, где ждут логическое значение, для него ошибка.