Разработчик выпустил вход по токенам, обрадовался, что сервер больше не хранит сессии, и уехал в отпуск. Через месяц выяснилось, что уволенный сотрудник продолжает ходить по системе, потому что срок действия его токена истекает через полгода. Отозвать конкретный токен невозможно: подпись верна, а списка отозванных значений нет. Знакомая ловушка, и именно она объясняет, почему тема ротации выходит на первый план.
Компактный токен состоит из трёх частей и легко читается без ключей
Стандарт появился в конце 2010 года как черновик, а окончательная редакция увидела свет в мае 2015 года. Токен представляет собой строку из трёх частей, разделённых точками. Первая часть это заголовок, вторая полезная нагрузка, третья подпись.
Заголовок сообщает, какой алгоритм использован для подписи. Полезная нагрузка содержит утверждения, то есть набор полей о пользователе и сессии. Подпись вычисляется по первым двум частям с использованием секрета или закрытого ключа.
Кодирование выполняется особым вариантом base64, пригодным для вставки в адрес. Отсюда следствие, о котором забывают чаще всего: содержимое токена не зашифровано. Любой человек может раскодировать вторую часть и прочитать, что там написано.
import base64, json
def decode_payload(token):
part = token.split(".")[1]
padding = "=" * (-len(part) % 4)
raw = base64.urlsafe_b64decode(part + padding)
return json.loads(raw)
print(decode_payload("eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiI0MiJ9.abc"))
Проверить это полезно каждому, кто только начинает работать с токенами. Внутри не должно лежать ничего секретного: ни паролей, ни номеров карт, ни персональных данных, которые нежелательно раскрывать.
Стандарт определяет семь зарегистрированных имён полей. Издатель указывает, кто выпустил токен. Субъект описывает, о ком он выдан. Аудитория перечисляет, для кого он предназначен. Срок действия задаёт момент, после которого токен недействителен. Поле "не раньше" запрещает использование до указанного времени. Время выпуска фиксирует момент создания. Идентификатор даёт уникальное имя конкретному токену.
Эти поля превращаются из формальности в защиту, если их проверять. Разберём почему.
Атака через подмену алгоритма объясняет строгое требование проверки
Историческая уязвимость связана с тем, как библиотеки выбирали алгоритм проверки. Ранняя реализация доверяла заголовку токена и брала алгоритм оттуда. Злоумышленник мог указать алгоритм отсутствия подписи и подделать содержимое: проверка проходила успешно, потому что проверять было нечего.
Другой вариант той же ошибки касался путаницы между симметричным и асимметричным алгоритмом. Если сервер ожидает проверку открытым ключом, а заголовок требует симметричный алгоритм, открытый ключ может быть использован как общий секрет. Атакующий, знающий открытый ключ, подписывает токен сам.
Лекарство одно и оно категорично. Библиотека проверки должна работать с заранее заданным списком разрешённых алгоритмов, а значение из заголовка используется только для сверки, а не для выбора.
import jwt
ALLOWED = ["RS256"]
def verify(token, public_key, issuer, audience):
return jwt.decode(
token,
public_key,
algorithms=ALLOWED,
issuer=issuer,
audience=audience,
options={"require": ["exp", "iat", "sub"]},
)
Здесь видно три уровня защиты. Список алгоритмов фиксирован. Издатель и аудитория проверяются. Обязательные поля перечислены явно, и токен без срока действия будет отвергнут.
Последнее требование недооценивают. Токен без поля срока действия действителен вечно. Если такой токен утечёт, закрыть доступ можно только сменой ключа подписи, а это разлогинит всех пользователей сразу.
Короткий срок жизни и обновление парой токенов решают задачу отзыва
Главное противоречие темы состоит в следующем. Токен проверяется без обращения к базе, и именно в этом его сила. Но если нет обращения к базе, нет и возможности отозвать конкретный токен.
Компромисс строится на разделении ролей между двумя токенами.
Токен доступа живёт коротко. Пятнадцать минут, час, иногда пять минут. Он передаётся с каждым запросом и проверяется без обращения к хранилищу. Утечка такого токена даёт злоумышленнику небольшое окно.
Токен обновления живёт долго. Дни, недели, иногда месяцы. Он передаётся редко, только на специальный адрес, и хранится на стороне сервера в базе или в защищённом хранилище. Поскольку обращение к базе здесь всё равно происходит, токен можно отозвать в любой момент.
async function refreshSession(refreshToken) {
const stored = await refreshStore.find(refreshToken);
if (!stored || stored.revokedAt || stored.expiresAt < Date.now()) {
throw new Error("Обновление отклонено");
}
const rotated = await refreshStore.issue({
userId: stored.userId,
family: stored.family,
});
await refreshStore.revoke(stored.id, rotated.id);
return {
accessToken: signAccessToken({ sub: stored.userId }, "15m"),
refreshToken: rotated.value,
};
}
Возврат обоих токенов в ответе это не мелочь, а часть схемы. Каждое обновление выдаёт новую пару, а старый токен обновления помечается использованным.
Обнаружение повторного использования выдаёт попытку взлома
Ротация открывает возможность, которой нет у обычной схемы. Если старый токен обновления вдруг предъявляют снова, значит, у кого-то есть его копия.
Логика выглядит так. Токены одной сессии объединяются в семейство. При обновлении выпускается новый член семейства, а предыдущий помечается как заменённый. Если помеченный токен предъявляют повторно, это признак кражи, и всё семейство отзывается целиком.
async function rotate(refreshToken) {
const record = await refreshStore.find(refreshToken);
if (!record) {
throw new Error("Неизвестный токен");
}
if (record.status === "replaced") {
await refreshStore.revokeFamily(record.family);
await audit.log("повторное использование токена", {
family: record.family,
userId: record.userId,
});
throw new Error("Сессия отозвана");
}
const next = await refreshStore.issue({
userId: record.userId,
family: record.family,
});
await refreshStore.markReplaced(record.id, next.id);
return next;
}
Цена такой защиты состоит в том, что честный пользователь может пострадать от гонки. Два параллельных запроса на обновление приходят одновременно, первый успевает, второй получает признак замены, и всё семейство отзывается. Лекарство простое: небольшое окно допустимости, в течение которого повторное предъявление того же токена возвращает уже выпущенную пару, а не запускает тревогу.
Второе следствие касается сети. Если ответ с новой парой не дошёл до клиента из-за обрыва, клиент повторит запрос со старым токеном. Окно допустимости спасает и здесь, поэтому его стоит сделать чуть больше, чем задержка сети в худшем случае.
Хранение токенов на клиенте определяет поверхность атаки
Токен нужно где-то держать, и у каждого места свои риски.
Локальное хранилище браузера доступно любому сценарию на странице. Уязвимость, позволяющая исполнить чужой код, немедленно даёт доступ ко всем токенам. При этом хранилище удобно и переживает перезапуск браузера.
Память сценария безопаснее в отношении сторонних скриптов, но теряется при перезагрузке страницы. Пользователю приходится входить заново, что раздражает.
Защищённый от чтения сценарием cookie решает основную часть проблемы. Сценарий не может прочитать такой cookie, и уязвимость исполнения кода уже не даёт напрямую забрать токен. Зато появляется угроза подделки запросов, для защиты от которой применяется требование определённого заголовка и ограничение домена.
Практический подход состоит в разделении. Токен обновления хранится в защищённом cookie, доступном только серверной части. Токен доступа живёт в памяти страницы и не сохраняется между перезагрузками. Такая схема убирает самые грубые сценарии утечки и сохраняет удобство.
Проверка всех обязательных полей закрывает целый класс уязвимостей
Проверка подписи необходима, но недостаточна. Токен с верной подписью может быть выдан не тем сервисом, предназначаться другому получателю или быть выпущен для иной цели.
Издатель и аудитория проверяются обязательно. Если в системе несколько сервисов, каждый из них должен принимать только токены, выданные для него. Без проверки аудитории токен, выпущенный для внутреннего сервиса, подойдёт для публичного интерфейса.
Отдельная тонкость связана с разными видами токенов в одной системе. Токен подтверждения почты, токен доступа и токен для сброса пароля могут быть подписаны одним ключом. Если не различать их назначение, пользователь сможет подставить токен сброса пароля вместо токена доступа. Разделение достигается либо разными ключами, либо явным полем назначения и его проверкой.
Проверка срока действия тоже требует аккуратности. Часы серверов могут расходиться, поэтому допускается небольшой запас, обычно не больше минуты. Слишком большой запас сводит защиту на нет, слишком маленький даёт ложные отказы.
import time
LEEWAY = 30
def check_time(claims, now=None):
current = now or int(time.time())
if "exp" in claims and current > claims["exp"] + LEEWAY:
raise ValueError("Срок действия истёк")
if "nbf" in claims and current < claims["nbf"] - LEEWAY:
raise ValueError("Токен ещё не действует")
if "iat" in claims and current < claims["iat"] - LEEWAY:
raise ValueError("Время выпуска в будущем")
Отдельно стоит проверить поле идентификатора. Оно открывает возможность вести список отозванных токенов, не сохраняя каждый выпущенный. Список отозванных обычно на порядки короче списка выданных, и это делает схему практичной.
Ключи требуют управления и плановой замены
Токены наследуют прочность ключа, которым подписаны. Слабый секрет ломается перебором, и никакая аккуратная проверка не поможет.
Симметричный алгоритм использует один общий секрет. Он должен быть случайным и длинным, не короче длины выхода хеш-функции. Пароль, придуманный человеком, здесь не годится, как и строка из примера в документации.
Асимметричный алгоритм удобнее в распределённой системе. Сервис, выпускающий токены, держит закрытый ключ, остальные сервисы проверяют подпись открытым. Закрытый ключ не покидает один узел, и это резко сокращает поверхность утечки.
Смена ключа планируется заранее. Резкая замена ломает все действующие токены и выкидывает пользователей. Плавная схема работает через набор ключей: в заголовке токена указывается идентификатор ключа, сервис проверки ищет ключ по этому идентификатору и поддерживает одновременно старый и новый.
const keys = new Map([
["2026-08", { publicKey: oldPublic, retiredAt: "2026-11-01" }],
["2026-10", { publicKey: newPublic }],
]);
function pickKey(token) {
const [rawHeader] = token.split(".");
const header = JSON.parse(Buffer.from(rawHeader, "base64url").toString());
const entry = keys.get(header.kid);
if (!entry) {
throw new Error("Неизвестный ключ подписи");
}
return entry.publicKey;
}
Идентификатор ключа становится обязательным полем, и его отсутствие трактуется как ошибка. Иначе сервис попытается перебрать все ключи подряд, а это лишняя работа и потенциальная путаница.
Наблюдаемость превращает защиту из теории в практику
Настроенная схема может незаметно разъехаться с реальностью. Признаки помогают заметить это заранее.
Первый показатель это доля отклонённых токенов по причинам. Рост отказов из-за истёкшего срока говорит о проблемах синхронизации или коротких сроках. Рост отказов по подписи сигналит о неверном ключе или попытке подделки.
Второй показатель это повторные использования токенов обновления. Каждое такое событие стоит разбирать: иногда это гонка, иногда кража.
Третий показатель это число активных сессий на пользователя. Внезапный рост может означать, что токены обновления утекают и злоумышленник создаёт собственные семейства.
Четвёртый показатель это время от выпуска до использования. Массовое использование самых старых токенов выглядит подозрительно и заслуживает проверки.
Ротация токенов получилась ответом на противоречие, заложенное в самой природе JWT. Токен силён тем, что проверяется без обращения к хранилищу, и слаб ровно тем же. Пара из короткого и длинного токена разводит эти свойства по разным местам, а привычка сверять все поля закрывает то, что осталось. Аккуратно собранная схема не выглядит эффектно, зато спокойно переживает и увольнение сотрудника, и утечку устройства.