Содержание урока · 9 разделов
Аутентификация по JWT обещает состояние без хранилища: подпись верна, exp не вышел — токен годен. Обещание превращается в проблему в трёх ситуациях: пользователь нажал «выйти», а его access живёт ещё четырнадцать минут; аккаунт забанен, а его токены работают; пароль сменён после кражи, а украденная сессия ходит по API. Отзыв jwt токена возвращает контроль: denylist — список идентификаторов отозванных токенов в Redis, который middleware сверяет на каждом запросе. Полного stateless больше нет — за контроль заплачена одна проверка хранилища, и это честная цена: без неё единственным «отзывом» остаётся короткий exp, который сокращает окно, но не закрывает его. В уроке: выпуск с уникальным jti, запись в denylist с TTL до exp, проверка после Auth и развилка fail-open против fail-closed, на которой спорят все команды.
jti: идентификатор, по которому отзывают#
Отзывают не строку токена целиком — идентификатор из claims. Поле jti (JWT ID) живёт в jwt.RegisteredClaims как ID: сервер генерирует его при выпуске, шестнадцать случайных байт из crypto/rand в hex дают практически нулевую вероятность коллизии. Именно уникальность делает jti пригодным: denylist хранит «этот конкретный токен отозван», а не «этот пользователь» — отзыв чужих сессий по userID через denylist сделать нельзя, там просто нет такого ключа. Хеш самой строки токена тоже вариант, но он длиннее и хуже читается в логах: ключ deny:9f86d081 по jti короче и говорит сам за себя. Если токены выпускаются без jti — как все примеры до четвёртого урока про claims — отзыву не за что зацепиться, и первое изменение будет именно в выпуске.
Выпуск с уникальным jti
GOfunc newJTI() (string, error) {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
return "", err
}
return hex.EncodeToString(b), nil
}
func IssueWithJTI(userID int64, role string) (string, error) {
jti, err := newJTI()
if err != nil {
return "", err
}
claims := AppClaims{
UserID: userID,
Role: role,
RegisteredClaims: jwt.RegisteredClaims{
ID: jti,
ExpiresAt: jwt.NewNumericDate(time.Now().Add(15 * time.Minute)),
},
}
return jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(secret)
}crypto/rand даёт шестнадцать случайных байт, hex — строку из тридцати двух символов; она уходит в RegisteredClaims.ID и живёт в payload, а не в header. ID — единственное новое поле: остальное повторяет выпуск из урока про claims. Предупреждение на будущее: logout ниже читает claims.ID, поэтому пустой jti — это баг выпуска, который проявится не при выпуске, а в отзыве.
Logout: запись в denylist с TTL до exp
GOfunc logout(r *http.Request) error {
claims, ok := ClaimsFromContext(r.Context())
if !ok {
return errors.New("нет аутентификации")
}
ttl := time.Until(claims.ExpiresAt.Time)
if ttl <= 0 {
return nil
}
return redisClient.Set(r.Context(),
"deny:"+claims.ID, "1", ttl).Err()
}TTL вычисляется как time.Until от exp: ровно столько, сколько токен ещё прожил бы. Запись умирает сама в тот же момент, когда умер бы токен, поэтому чистка denylist не нужна вообще — за это отвечает Redis. Отрицательный TTL означает истёкший токен: писать его в список бессмысленно, и logout просто ничего не делает.
Проверка denylist в middleware#
Место проверки — между Auth и хендлером: сначала подпись и сроки разбирает существующий слой, потом новый слой NotRevoked смотрит jti в Redis, и только затем хендлер делает работу. Порядок важен: до разбора токена проверять нечего — jti живёт в claims. Команда Exists вместо Get короче и не тащит значение: ноль — ключа нет, токен годен; единица — отозван. Цена решения — один Redis-поход на каждый защищённый запрос: на локальной сети это доли миллисекунды, и за них вы получаете мгновенный logout вместо четырнадцати минут ожидания. Кэшировать отрицательный ответ «не отозван» внутри одного запроса можно, между запросами — нельзя: кэш между запросами превращает мгновенный отзыв в отложенный, и весь смысл решения пропадает.
NotRevoked: слой между Auth и хендлером
GOfunc NotRevoked(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
claims, ok := ClaimsFromContext(r.Context())
if !ok {
http.Error(w, "нет аутентификации", http.StatusUnauthorized)
return
}
n, err := redisClient.Exists(r.Context(), "deny:"+claims.ID).Result()
if err != nil {
http.Error(w, "сервис проверки недоступен", http.StatusServiceUnavailable)
return
}
if n > 0 {
http.Error(w, "токен отозван", http.StatusUnauthorized)
return
}
next.ServeHTTP(w, r)
})
}Три исхода разделены честно: ключ не найден — next, ключ найден — 401 «токен отозван», Redis недоступен — 503. Exists возвращает количество найденных ключей; ошибка соединения не смешивается с ответом «отозван», это разные состояния. Слой читает те же claims из контекста, что и RequireRole из одиннадцатого урока: стек Auth, NotRevoked, RequireRole собирается без правок друг в друге.
Вариант fail-open: проверки нет — пропускаем запрос, сервис жив, и мимо проходят отозванные токены ровно пока лежит Redis. Вариант fail-closed: 503 на все защищённые маршруты — отзыв работает всегда, но Redis становится единым местом отказа входа. Моё правило для аутентификации: fail-closed, потому что fail-open делает отзыв игрушкой — нападающему достаточно устроить сбой Redis, чтобы отозванные токены ожили. Граница, где я допускаю open, — внутренние инструменты с низким риском и без чужих сессий, где минутная недоступность входа дороже самого отзыва. Рядом держите health-check и алерт: fail-closed без мониторинга превращает падение Redis в непонятную волну 503.
TTL до exp: почему список не раздувается#
Элегантность схемы — в вычислении срока записи. Запись deny:jti живёт ровно до exp отозванного токена: после этого момента токен всё равно не прошёл бы проверку сроков, и упоминать его дальше незачем. Redis сам удаляет ключи по TTL, и denylist не требует ни фоновых чистильщиков, ни лимитов размера: память занята только реально отозванными и ещё живыми токенами. Числа: access на пятнадцать минут даёт максимум пятнадцать минут жизни записи; тысяча выходов в час — около тысячи ключей в пике, ничего. Растёт список только при отзыве refresh-токенов с длинным exp — формула та же, просто TTL больше. Если access короче минуты, размер denylist остаётся ничтожным: он пропорционален числу отзывов, а не числу всех выданных токенов.
Спорное мнение: одному инстансу Redis не нужен#
Если сервис ровно один, крутится в единственном экземпляре, а denylist нужен ради logout — я бы держал его в памяти процесса: map с jti и горутина-чистильщик, раз в минуту выбрасывающая просроченные записи. Ноль сетевых поездок, ноль зависимости от Redis, TTL до exp работает так же. Граница проходит по второму инстансу: два пода за балансировщиком означают два разных списка, и отозванный в одном токен спокойно живёт в другом — вот здесь общий Redis становится не оптимизацией, а условием корректности. Вторая оговорка: процесс с denylist в памяти теряет список при рестарте, и мгновенный logout пропадает на время простоя. Для многих команд это приемлемая цена, но принять её надо письменно, на ревью, — а не обнаружить во время разбора инцидента.
Что отзывать на какие события#
- Logout: текущий jti access-токена — окно отзыва равно оставшемуся сроку, обычно минуты.
- Refresh при ротации: старый помечается отозванным в базе — он хранится там с рождения, как в уроке про refresh-токены.
- Смена пароля: все токены пользователя — одним jti не обойтись; проще всего версия пользователя в claims, которая растёт при смене и сверяется на проверке.
- Бан аккаунта: тот же механизм версии пользователя плюс отзыв refresh в базе.
- Утечка ключа подписи: это не denylist, а ротация kid из урока про JWKS — старые подписи умирают вместе с ключом.
- Компрометация конкретного токена: его jti в denylist с TTL до exp — точечный ответ без побочных эффектов.
Кейс: бан в базе есть, а токены ходят#
Схема из реального разбора: модератор банит нарушителя, запись пользователя получает флаг, а API продолжает принимать его запросы — access подписан, exp не вышел, denylist пуст, потому что logout никто не вызывал. Бан, который действует до истечения exp, — не бан, а отсрочка: при пятнадцатиминутном access терпимо, при refresh-сессии на месяц — неделя плохих новостей. Лечение двухслойное: при бане отзывается refresh в базе, где он хранится, а access отсекается сверкой статуса — либо флаг загружается на проверке, либо в claims живёт token_version, которая растёт при бане и перестаёт совпадать на следующем же запросе. Denylist тут не помощник: он гасит конкретные jti, а их у пользователя столько, сколько устройств — по одному на каждую сессию.
Отдельно про refresh: настоящий долгий доступ к аккаунту живёт в нём, а не в пятнадцатиминутном access. Поэтому denylist ставят в пару к хранению refresh-токенов: access гасится списком на минуты, refresh — записью в базе, где он и так лежит для ротации. Схема из урока про refresh-токены уже хранит refresh — logout удаляет его и добавляет jti access в denylist. Отзыв одного access без отзыва refresh — полумера: пользователь вышел из браузера, а украденный refresh продолжает обновлять пары. Правило пары: logout гасит оба, и тогда «выйти» действительно значит выйти.
Проверка: срок записи
Какой TTL ставится записи deny:jti?
Проверка: fail-closed на практике
Redis недоступен, политика fail-closed. Что получают пользователи?
Токен без jti
Прочитайте код и выберите, что он выведет:
// токен выпущен без jti: RegisteredClaims.ID пуст
// однажды в Redis записался ключ "deny:" — logout токена без jti
// приходит запрос с другим токеном, тоже без jti
n, _ := redisClient.Exists(ctx, "deny:"+claims.ID).Result()
// что вернёт n?Допишите отзыв токена
Восстановите расчёт TTL до exp и запись ключа в Redis.
Соберите начало logout-хендлера
Расставьте строки в правильном порядке: от сигнатуры до тихого отказа без claims.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Понадобятся локальный Go и Redis: docker run -p 6379:6379 redis, go get github.com/redis/go-redis/v9. Выпустите токен с jti, сделайте logout, посмотрите ключ через redis-cli по маске deny:* и его TTL. Затем приложите отозванный токен к защищённому маршруту — получите 401, а через exp записи попробуйте снова: токен уже отклонит проверка сроков. Механика TTL разобрана в уроке про TTL курса go-redis.
Карточки: отзыв токенов
Denylist закрывает главный вопрос, который задают JWT-скептики: «а если пользователь вышел?» Теперь ответ есть: jti плюс Redis с TTL до exp. Дальше сессия переезжает в клиентскую часть: в уроке про HttpOnly cookie токен living в куки, которую скрипт не читает, и logout совмещается с её очисткой. Происхождение jti и остальных registered-полей — в уроке про claims, а границы stateless-модели и споры «сессии против токенов» продолжатся в уроке про сервисные токены. Хранилище вокруг denylist — это вся территория курса go-redis: от строк и TTL до распределённых блокировок. Карта всех тем — на странице курса jwt-go.
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- Отзыв jwt токена строится на denylist по jti: уникальный идентификатор из claims отзывает конкретный токен.
- Logout пишет deny:jti через redis.Set с TTL ровно до exp: запись умирает вместе с токеном, чистка не нужна.
- Проверка — слой NotRevoked между Auth и хендлером: Exists по ключу, один Redis-поход за запрос.
- Redis упал: fail-closed даёт 503 и сохраняет гарантию отзыва; fail-open пропускает отозванные токены во время сбоя.
- Токены без jti отзываются одним ключом deny: — уникальность идентификатора обязательна при выпуске.
- Refresh-токены отзываются в базе, где живут; access — denylist-ом на оставшиеся минуты.
Домашнее задание
Добавьте jti в выпуск, logout с Set и TTL до exp и слой NotRevoked после Auth. Проверьте три сценария: logout до exp даёт 401, после exp — отказ сроков, остановленный Redis — 503. Затем добавьте token_version в claims и реализуйте отзыв всех токенов пользователя при смене пароля; сравните объём кода с denylist-вариантом.
Личные заметкиПусто
Вопросы и ответы по уроку
Не теряется ли главный плюс JWT — отсутствие состояния?
Частично: каждый запрос ходит в Redis. Но состояние остаётся дешевле сессионного: хранятся только отозванные идентификаторы с коротким TTL, а не все активные сессии. Если и эта цена не нужна — укорачивайте exp и живите с окном риска; выбор зависит от того, сколько стоит украденная сессия.
Почему отзывать по jti, а не по userID?
Отзыв по userID — это отзыв всех токенов пользователя разом, что правильно при бане и смене пароля, но слишком широко для logout: гаснут сессии на всех устройствах. jti даёт точечность, а массовый отзыв делается версией пользователя поверх.
Что если один сервис забыл проверять denylist?
Отозванный токен будет работать именно там: denylist — часть контракта аутентификации, и каждый проверяющий обязан его сверять. Централизуйте слой в общей библиотеке middleware, как делали с Auth, — тогда дыра в одном сервисе станет ошибкой сборки, а не инцидентом.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Изучайте дальше
Следующая ступень учебного пути
Telegram-боты на Go: от эха до полноценного сервиса
Учимся писать Telegram-ботов на Go: токен BotFather, команды, клавиатуры, состояния диалога, медиа, рассылки, webhook и деплой. 25 уроков с кодом.
- Создавать бота через BotFather и хранить токен безопасно
- Получать обновления через long polling и webhook
- Отвечать сообщениями, командами и клавиатурами
- Строить многошаговые диалоги с сохранением состояния