Почему JWT нельзя отозвать, и единственная схема, где можно

Почему JWT нельзя отозвать, и единственная схема, где можно

Как отозвать JWT до истечения срока, почему черный список по jti в Redis - это ответ, к которому приходят все, и как Versola edge делает это через Postgres LISTEN/NOTIFY: реальные HTTP-примеры.

Загуглите, как отозвать JWT, и первый же ответ будет: нельзя. JWT stateless, значит токен валиден до exp, что бы ни случилось в промежутке: пользователь вышел, админ заблокировал аккаунт, служба безопасности подтвердила, что сессию украли. Дальше идет стандартный совет. Держите access token коротким, отзывайте refresh, примите окно как данность.

Мы тоже так думали, пока фрод-команда банка не попросила убить скомпрометированную сессию меньше чем за секунду. Ответ “access token формально валиден еще пять минут” их не устроил, и правильно.

Это верно только отчасти. Подпись доказывает, что было правдой в момент выпуска токена, и она не может сказать, отменили ли это позже. Ломается следующий шаг: “значит, JWT отозвать нельзя” - это утверждение не про подписи. Это утверждение про допущение, которое почти никогда не проговаривают: что бы ни проверяло токен, оно обязано вынести решение, опираясь только на сам токен.

Amazon описывает проблему точнее всех

У Cognito есть и revocation endpoint, и GlobalSignOut, так что отзыв access token там решен. Посмотрите, что при этом происходит по документации:

Revoked tokens can’t be used with any Amazon Cognito API calls that require a token. However, revoked tokens will still be valid if they are verified using any JWT library that verifies the signature and expiration of the token. Документация AWS Cognito, отзыв токенов

И там же, в базе знаний, еще прямее:

Note that only Amazon Cognito is informed of the token revocation. Your application might continue to accept the tokens until they expire. AWS re:Post, logout endpoint и GlobalSignOut

Отзыв состоялся. Он записан, надежно, на стороне провайдера. А ваш API продолжает принимать токен, потому что валидирует JWT библиотекой, локально, по одному токену. В этом коде просто нет способа узнать то, что знает Cognito.

Это не ограничение JWT. Это ограничение топологии, описанное вендором в его же документации, и оно одинаковое у любого провайдера, который выдает JWT, а валидирует его каждый ваш сервис самостоятельно.

Короткий TTL и арифметика, которую он не отменяет

Стандартная мера - сжать окно, а не закрыть его. Сократите TTL access token с часа до пяти минут, и украденный токен умрет максимум через пять минут сам.

Это же означает, что каждый клиент обновляется в двенадцать раз чаще: в двенадцать раз больше нагрузки на token endpoint, в двенадцать раз больше round-trip’ов за refresh - за окно, которое все равно шириной в пять минут. Для фрод-команды, которая прямо сейчас смотрит, как со счета уводят деньги, пять минут - это долго.

Короткий TTL не закрывает разрыв. Он сдает его в аренду короче и дороже.

Черный список и его настоящая цена

Второй стандартный ответ - черный список. Кладем jti в каждый токен, при отзыве пишем этот jti в Redis с TTL равным остатку жизни токена, и каждый запрос сверяется с черным списком. У подхода много вариаций: счетчик версии токена на пользователя, not-before таймстамп на субъект, ограничивающий все выпущенное раньше, полноценный черный список по токену.

Все это работает. И ровно здесь возникает возражение, которое повторяют как приговор: теперь у вас лукап на каждом запросе, токен больше не stateless, так зачем вообще JWT. Обычно на этом разговор заканчивают. Стоит сделать еще один шаг и спросить, сколько именно стоит этот лукап - потому что ответ целиком зависит от того, кто именно смотрит в список.

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

Мы построили ровно одну копию этой машинерии для Versola edge: поток Postgres LISTEN/NOTIFY в структуру в памяти, курсор докатки, чтобы реплика, пропустившая уведомление, дочитала, а не догадывалась, fail-closed на старте, чтобы реплика, которая не смогла прочитать список, вообще не бралась обслуживать трафик. Дальше - про то, почему это стоит на Postgres, а не на отдельном новом хранилище.

Один раз - это проект. N раз, в каждом сервисе, - это причина, по которой почти никто так не делает, и по которой совет про короткий TTL побеждает по умолчанию.

Четыре места, где может жить состояние отзыва

Где живет состояниеЦенаКто так делает
Нигде, только короткий TTLокно уязвимости равно TTL, примерно 12x нагрузки на auth при 5 минутах вместо 60стандартный совет и дефолтная позиция большинства провайдеров
В каждом сервисе независимоN кэшей, N подписок на обновления, N путей докатки, N шансов ошибиться в деталяхчерные списки по jti, вкрученные в JWT-middleware каждого сервиса
На auth-сервере, синхронно на каждый запроссетевой round-trip на каждый API-вызовлюбой дизайн, где валидация ходит наружу прямо в hot path
Один прокси перед всемодин кэш, один курсор, лукап в хэшмапе без сетевого вызоваVersola edge

Вторая строка - честная версия совета про черный список в Redis, и дорога она поддержкой, а не вычислениями. Третья меняет поддержку на задержку каждого запроса: рабочий вариант и тема для отдельной статьи. Четвертая - та же машинерия, что во второй, построенная один раз, потому что держать ее нужно ровно одному семейству процессов.

flowchart LR
    subgraph N["Every service validates independently"]
        direction TB
        SA["Service A"] -->|own LISTEN, own cache, own cursor| DBA[("Postgres")]
        SB["Service B"] -->|own LISTEN, own cache, own cursor| DBA
        SC["Service C"] -->|own LISTEN, own cache, own cursor| DBA
    end

    subgraph P["One proxy validates for everything"]
        direction TB
        Client(["Client request"]) --> Edge["edge replica"]
        Edge -->|one LISTEN, one cache, one cursor| DBP[("Postgres")]
        Edge --> SA2["Service A"]
        Edge --> SB2["Service B"]
        Edge --> SC2["Service C"]
    end

Почему четвертая строка - предусловие, а не прием

Вот тут легко переборщить с продажей. Четвертая строка - не трюк, который любая архитектура возьмет, если постарается. Она доступна ровно тогда, когда прокси уже терминирует каждый запрос до бэкенда, а edge в архитектуре Versola этим и занимается по причинам, никак не связанным с отзывом: роутинг, authn/authz-энфорсмент, рейт-лимиты, обычный набор.

Если сервисы валидируют JWT независимо, общий кэш поверх них не переносит вас из второй строки в четвертую - он добавляет пятую вещь, которую надо синхронизировать между N сервисами, то есть ту же вторую строку с лишним шагом. Четвертая требует сначала отказаться от независимой валидации, и это архитектурное решение, а не выбор библиотеки.

Так что утверждение не в том, что у нас быстрый кэш. Быстрый лукап в хэшмапе - не новость. Утверждение в том, что этот лукап достижим только из одной стартовой точки, и если вы не в ней, никакой инженерией это свойство не получить. Это стоит говорить прямо, иначе команда строит худшую версию второй строки, считая, что построила четвертую.

Почему Postgres, а не отдельное хранилище

Вот чему четвертая строка реально должна была соответствовать: ноль обращений к базе на пути запроса, отзыв виден везде примерно через секунду, а не при следующем опросе, ни одна реплика не обслуживает трафик, не загрузив список, и запас на массовый отзыв в миллион записей в памяти. Первое и третье тянут в разные стороны. “Ноль обращений к базе” и “никогда не пропустить отозванный токен” уживаются только если у того, кто отвечает на вопрос, ответ уже лежит в кэше до того, как пришел запрос.

Очевидный ход - взять Redis: черный список по jti с TTL - это ответ, который дает любой поиск, и он бы тоже сработал. Мы не стали, потому что у edge уже есть своя Postgres на каждое развертывание как источник правды для всего остального, что он держит: клиенты, сессии, все прочее, и каждая реплика и так делит с ней пул соединений просто по факту того, что это edge. Поднимать рядом Redis ради одной фичи - значит завести второе хранилище, которое надо разворачивать, бэкапить и отдельно продумывать поведение при отказах, чтобы держать в нем состояние, которое Postgres унесет бесплатно. LISTEN/NOTIFY дает ровно то, что добавил бы Redis: push вместо опроса. Отзыв, записанный любой репликой, - это строка, к которой у каждой другой реплики уже есть соединение, и Postgres сам сообщает о ней, вместо того чтобы заставлять их спрашивать.

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

sequenceDiagram
    autonumber
    participant Ops as Fraud team
    participant Auth as auth (OP)
    participant E1 as edge replica 1
    participant PG as edge's Postgres
    participant E2 as edge replica 2

    Ops->>Auth: DELETE /users/sessions  {userId}
    Auth->>Auth: End every session for userId, group clients by back_channel_logout_uri
    Auth-->>Ops: 200 OK

    Auth->>E1: POST /logout/backchannel  logout_token=(sub, toe, events)
    E1->>E1: Verify signature against JWKS, check iss/aud/events
    E1->>PG: INSERT INTO revocations (revoked_key='sub:user-42', issued_before=toe, ...)
    PG-->>PG: AFTER INSERT trigger -> NOTIFY 'revocation'

    par Every replica listens on its own connection
        PG-->>E1: NOTIFY revocation payload
        E1->>E1: entries.merge(key, revocation, widest)
    and
        PG-->>E2: NOTIFY revocation payload
        E2->>E2: entries.merge(key, revocation, widest)
    end

    note over E2: Next request carrying a token issued before toe
    E2->>E2: isRevoked(keys, iat) - ConcurrentHashMap.get, no query
    E2-->>E2: 401 Unauthorized

Служба безопасности из вступления не вызывает /revoke с токеном на руках - токена у нее и нет. Она вызывает внутренний endpoint с ID пользователя:

DELETE /users/sessions HTTP/1.1
Host: auth.internal.example.com
Authorization: Bearer <admin-token>

{ "userId": "user-42" }

auth завершает все сессии этого пользователя и отправляет один back-channel logout-токен на каждый клиент, а не на каждую сессию: пользователь с пятью сессиями на двух клиентах стоит одну-две доставки, а не пять. sid в этом токене намеренно нет: по OIDC back-channel logout голый sub означает “закончить все сессии этого пользователя”. edge проверяет подпись по JWKS auth и пишет одну строку, которая переживет отдельные сессии:

INSERT INTO revocations (revoked_key, revoked_at, expires_at, issued_before)
VALUES ('sub:user-42', now(), '2025-01-01 14:00:00+00', '2026-08-25T14:00:00Z')

Триггер AFTER INSERT вызывает pg_notify('revocation', ...), listener каждой реплики его подхватывает, и примерно через секунду после запроса службы безопасности каждый access-токен этого аккаунта перестает работать - и те, что на руках у злоумышленника, и те, что в других вкладках владельца. В этом цена sub-широкого убийства: на уровне JWT отличить злоумышленника от владельца нечем. Владелец залогинится заново и получит токен, выпущенный после границы, - для этого и существует issued_before.

Тот же столбец с ключом несет две более узкие гранулярности через префикс вместо отдельного механизма на каждую:

ПрефиксУбиваетКто запускает
sub:все токены пользователя, вездеадмин или безопасность, закрывающие доступ
jti:один access-токенсобственный /revoke клиента (RFC 7009)
sid:все токены одной SSO-сессииобычный логаут

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

У NOTIFY нет повторной доставки. Оборвалось соединение - все, что опубликовали за это время, пропало, молча, и это и есть настоящий довод в пользу того, чтобы держать это на базе, а не на fire-and-forget pub/sub. Дыру закрывают три вещи. При старте реплика читает весь список отзывов, прежде чем обслужить хоть что-то, и отказывается стартовать, если не смогла: пустой кэш - это не устаревший ответ, а неверный. При реконнекте она продолжает от курсора, а не перечитывает все, и скорее применит горстку уже примененных отзывов повторно, чем рискнет пропустить один. Раз в десять минут в любом случае идет полная пересинхронизация как страховка, и в нормальной работе от нее ничего не зависит.

Что это не решает

Нужен прокси, который уже стоит в hot path. Если в вашем деплое не каждый запрос идет через одну точку, конструкция не “сложнее”, а недоступна. Короткий TTL с принятым окном - честный fallback для такой архитектуры, а не четвертая строка, собранная по частям.

У состояния есть допущение по размеру, а не потолок. Кэш держит в памяти все неистекшие отзывы без вытеснения: за безопасность отвечает не лимит, а expiry-таймстамп в каждой записи. Миллион записей - это примерно 250MB на реплику. Цифра получена арифметикой, а не нагрузочным тестом на таком объеме.

Потеря базы ухудшает ответ, а не останавливает трафик. Fail-closed работает только на старте: реплика, которая не смогла прочитать список, отказывается подниматься и тем самым не попадает под балансировщик. Та, что уже обслуживает запросы и потеряла Postgres, продолжает проксировать по последнему загруженному списку, пишет warning и пишет в метрику, насколько он устарел. Это сделано осознанно и это именно трейдофф: альтернатива - отбивать все запросы - превращает недоступность отзыва в полный отказ сервиса. Плата в том, что отзывы, записанные в это окно, не дойдут ни до кого, пока соединение не вернется.

Окно не нулевое. Между тем, как запись легла, и тем, как уведомление дошло до всех реплик, какой-то запрос где-то обслужится по старому кэшу. На практике доли секунды, но не гарантия.

Мы описываем собственную архитектуру, и это повод быть строже к границам, а не мягче. Четвертая строка существует только потому, что прокси уже стоял там.

Частые вопросы

Отзывает ли access token эндпоинт отзыва OAuth (RFC 7009)? Не обязательно. Спецификация разрешает серверу поддерживать только refresh и возвращать unsupported_token_type, и прямо отмечает, что для немедленного отзыва access token нужно какое-то взаимодействие между authorization server и resource server, которое она не стандартизирует. Как выглядит это взаимодействие - и есть тема статьи.

Черный список по jti в Redis - это ошибка? Нет. Это второй рабочий вариант. Его цена в том, что каждому сервису, который валидирует токены, нужна своя копия логики проверки и свой способ оставаться в курсе.

Как разлогинить пользователя на всех устройствах? Отзывать по субъекту, а не по id токена: одна запись покрывает все токены пользователя. Плюс таймстамп отсечки, чтобы пользователь, залогинившийся сразу заново, не пострадал - иначе вы забанили аккаунт, а не завершили его сессии.

Достаточно ли версионирования токена? Оно дает отзыв на уровне пользователя дешево и не умеет выразить “убей эту одну сессию” или “убей этот один токен”. Какие гранулярности нужны - продуктовый вопрос, разница в стоимости хранения между ними невелика.

← Все статьи