---
title: "Отзыв токенов: denylist по jti"
description: "Отзыв jwt токена в Go: denylist по jti в Redis, Set с TTL до exp, проверка между Auth и хендлером и fail-closed при недоступности Redis. Код и ошибки."
course: "JWT-аутентификация в Go: golang-jwt и bcrypt"
url: "https://golangmaster.ru/courses/jwt-go/lessons/15-otzyv-tokenov/"
published: "2026-09-19"
updated: "2026-10-02"
minutes: 18
---

# Отзыв токенов: denylist по jti

*Курс «JWT-аутентификация в Go: golang-jwt и bcrypt», урок 15 из 25. ~18 минут. Обновлено 02.10.2026. Источник: https://golangmaster.ru/courses/jwt-go/lessons/15-otzyv-tokenov/*

Отзыв jwt токена ломает главное обещание JWT — состояние без базы: пока не кончился exp, токен жив даже после выхода пользователя. Решение — denylist по jti в Redis: logout записывает идентификатор с TTL ровно до exp, middleware на каждом запросе сверяется со списком. Разбираем выпуск с jti, запись и проверку, TTL-арифметику и решение, которое придётся принять при падении Redis.

Аутентификация по 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](/courses/jwt-go/lessons/04-claims/) — отзыву не за что зацепиться, и первое изменение будет именно в выпуске.

### Выпуск с уникальным jti

```go
func 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

```go
func 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 и хендлером

```go
func 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 собирается без правок друг в друге.

> **Живое место: Redis упал — пускать или нет.** Вариант 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-токены](/courses/jwt-go/lessons/08-refresh-token/) уже хранит refresh — logout удаляет его и добавляет jti access в denylist. Отзыв одного access без отзыва refresh — полумера: пользователь вышел из браузера, а украденный refresh продолжает обновлять пары. Правило пары: logout гасит оба, и тогда «выйти» действительно значит выйти.

### Проверка: срок записи

**Вопрос:** Какой TTL ставится записи deny:jti?

- Постоянный: ключ удаляется вручную при чистке
- ✅ Ровно до exp отозванного токена
- Пятнадцать минут всегда
- Сутки по умолчанию

TTL вычисляется как time.Until(claims.ExpiresAt.Time): запись живёт ровно столько, сколько жил бы токен. После exp упоминание не нужно — проверку сроков токен не пройдёт и без denylist. Поэтому список не растёт бесконечно и фоновая чистка не требуется.

### Проверка: fail-closed на практике

**Вопрос:** Redis недоступен, политика fail-closed. Что получают пользователи?

- Токены проверяются без denylist, все работают
- ✅ 503 на защищённых маршрутах, публичные маршруты работают
- Панику всего сервиса
- Автоматический logout всех пользователей

Fail-closed возвращает 503 на защищённых маршрутах: проверка невозможна — запрос не проходит. Публичные маршруты не зависят от слоя проверки. Это цена мгновенного отзыва: Redis становится частью входа, и за ним следят health-check-ом и алертом.

### Токен без jti

```go
// токен выпущен без jti: RegisteredClaims.ID пуст
// однажды в Redis записался ключ "deny:" — logout токена без jti
// приходит запрос с другим токеном, тоже без jti

n, _ := redisClient.Exists(ctx, "deny:"+claims.ID).Result()
// что вернёт n?
```

**Что выведет программа?**

- 0: ключ deny: не относится к этому токену
- ✅ 1: пустой jti складывает все такие токены в один ключ deny:
- Ошибку формата jti
- Панику на конкатенации строк

claims.ID пуст, конкатенация даёт один и тот же ключ deny: для всех токенов без jti, и единственная запись отзывает их разом. Это не теоретическая уязвимость, а баг выпуска: без гарантии уникального jti denylist неприменим. Защита двухслойная — генерировать jti при выпуске и отказывать в проверке токенам с пустым ID.

### Допишите отзыв токена

Восстановите расчёт TTL до exp и запись ключа в Redis.

```go
ttl := time.___(claims.ExpiresAt.___)
if ttl <= 0 {
        return nil
}
return redisClient.___(r.Context(), "deny:"+claims.ID, "1", ttl).Err()
```

Подсказка: До момента отзывает time.Until, срок живёт в поле Time структуры RegisteredClaims, а ключ пишет метод Set.

Правильные ответы в пропусках: `Until`, `Time`, `Set`

### Соберите начало logout-хендлера

Расставьте строки в правильном порядке: от сигнатуры до тихого отказа без claims.

Правильный порядок строк:

1. `func logout(w http.ResponseWriter, r *http.Request) {`
2. `claims, ok := ClaimsFromContext(r.Context())`
3. `if !ok {`
4. `http.Error(w, "нет аутентификации", http.StatusUnauthorized)`
5. `return`
6. `}`

> **Как повторить у себя.** Понадобятся локальный 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](/courses/go-redis/lessons/05-ttl-i-istechenie/).

### Карточки: отзыв токенов

- **jti** — JWT ID из RegisteredClaims: уникальный идентификатор токена, генерируется при выпуске из crypto/rand. Основа denylist: отзывается конкретный токен, а не пользователь.
- **denylist** — Набор ключей deny:jti в Redis. Запись означает «этот токен отозван»; проверка — Exists между Auth и хендлером на каждом защищённом запросе.
- **TTL до exp** — Срок записи равен time.Until(ExpiresAt): после exp токен мёртв и сам, запись удаляет Redis. Список растёт пропорционально отзывам, а не всем токенам.
- **fail-closed** — Политика при недоступности Redis: 503 на защищённых маршрутах. Альтернатива fail-open пропускает отозванные токены во время сбоя и превращает отзыв в игрушку.
- **token_version** — Версия пользователя в claims для отзыва всех токенов сразу: растёт при смене пароля или бане, сверяется на проверке. Denylist отзывает токены по одному.

Denylist закрывает главный вопрос, который задают JWT-скептики: «а если пользователь вышел?» Теперь ответ есть: jti плюс Redis с TTL до exp. Дальше сессия переезжает в клиентскую часть: в [уроке про HttpOnly cookie](/courses/jwt-go/lessons/16-httponly-cookie/) токен living в куки, которую скрипт не читает, и logout совмещается с её очисткой. Происхождение jti и остальных registered-полей — в [уроке про claims](/courses/jwt-go/lessons/04-claims/), а границы stateless-модели и споры «сессии против токенов» продолжатся в уроке про сервисные токены. Хранилище вокруг denylist — это вся территория [курса go-redis](/courses/go-redis/): от строк и TTL до распределённых блокировок. Карта всех тем — [на странице курса jwt-go](/courses/jwt-go/).

## Коротко: что запомнить

- Отзыв 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, — тогда дыра в одном сервисе станет ошибкой сборки, а не инцидентом.
