JWT или сессии: чем платят за отсутствие состояния
Чем JWT отличается от сессий, почему токен нельзя отозвать и когда список отзыва обнуляет весь смысл JWT. Разбор с вердиктом для браузерных приложений и для сервисов.
Коротко. JWT покупает отсутствие серверного состояния ценой невозможности отозвать доступ. Как только понадобился мгновенный отзыв, вы возвращаете состояние обратно — и платите за него дважды.
Вопрос «JWT или сессии» задают так, будто это выбор между новым и старым. На деле это выбор между двумя разными разменами, и один из них почти всегда описан неполно.
Что происходит в каждом случае
Сессия. Сервер создаёт запись, кладёт идентификатор в cookie. На каждом запросе идентификатор превращается в запись — обращением к хранилищу. Состояние живёт на сервере.
JWT. Сервер подписывает набор утверждений и отдаёт клиенту. На каждом запросе подпись проверяется локально, обращения к хранилищу нет. Состояние живёт у клиента.
Отсюда обещание, ради которого JWT и берут: проверка не требует похода в базу. Любой экземпляр сервиса проверяет токен сам, общего хранилища сессий не нужно.
Цена, которую называют реже
Токен нельзя отозвать.
Подпись говорит: «этот набор утверждений выдал я и он действителен до такого-то времени». Проверяющий не знает и не может узнать, что за это время произошло. Пользователь нажал «выйти», администратор заблокировал учётную запись, права урезали — токен всё ещё валиден и будет валиден до истечения срока.
Час жизни токена означает час доступа после блокировки. Не «теоретически», а буквально.
Стандартный ответ — список отзыва: держать таблицу аннулированных токенов и проверять по ней. Здесь и происходит подмена: проверка снова требует похода в хранилище, то есть ровно то, ради избавления от чего JWT и брали. Вы получили сложность подписанных токенов и не получили их единственного преимущества.
Что лежит внутри токена
Заблуждение, которое встречается чаще, чем хотелось бы: JWT считают зашифрованным. Он не зашифрован.
Токен — это три части через точку: заголовок, полезная нагрузка
и подпись. Первые две закодированы base64url, а не зашифрованы.
Декодировать их может кто угодно, без ключа и без инструментов.
Отсюда правило: в полезной нагрузке не бывает секретов. Ни внутренних идентификаторов, которые не должны утечь, ни персональных данных сверх необходимого, ни тем более чего-либо похожего на пароль. Подпись гарантирует, что содержимое не подменили, — и ничего больше.
Проверка, которую делают неполно
Валидация токена — это не только проверка подписи.
Алгоритм. Заголовок токена сообщает, каким алгоритмом он подписан,
и наивная реализация этому верит. Отсюда два классических класса атак:
alg: none и подмена асимметричного алгоритма на симметричный, где
открытый ключ подсовывается как секрет для HMAC.
Правильно — не читать алгоритм из токена, а задавать ожидаемый на стороне проверки. Любая зрелая библиотека это умеет; вопрос в том, включено ли.
Получатель и издатель. Поля aud и iss существуют не для красоты.
Токен, выписанный одним сервисом для другого, не должен приниматься
третьим. Без проверки aud компрометация одного потребителя
превращается в доступ ко всем.
Время. exp проверяют почти всегда, nbf — редко. Допуск
на расхождение часов должен быть маленьким: минута, а не час.
Размер
Деталь, о которой вспоминают на проде.
Идентификатор сессии — тридцать два байта. JWT с несколькими полями и подписью — сотни байт, с ролями и правами легко за килобайт. И он едет в каждом запросе, включая запросы за картинками, если лежит в cookie на весь домен.
На мобильном соединении это заметно. Лечится тем, что в токен кладут идентификатор и срок, а права запрашивают отдельно и кэшируют, — но тогда возвращается вопрос, зачем нужен был токен.
Как это решается на самом деле
Схемой из двух токенов, и она не компромисс, а нормальный ответ.
Токен доступа живёт коротко — минуты. Он не отзывается, и это допустимо: окно между блокировкой и истечением измеряется минутами, а не часами.
Токен обновления живёт долго, хранится на сервере и отзывается. Он используется редко — только чтобы получить новый токен доступа.
Обращение к хранилищу происходит раз в несколько минут, а не на каждом запросе. Обмен честный: короткое окно доступа после отзыва в обмен на отсутствие состояния в горячем пути.
Обязательная деталь, которую пропускают: токен обновления должен ротироваться. Каждое использование выдаёт новый и аннулирует старый. Повторное предъявление старого означает кражу, и правильная реакция — аннулировать всю цепочку.
Где хранить в браузере
Отдельный вопрос, на котором ошибаются чаще, чем на выборе механизма.
localStorage доступен любому скрипту на странице. Одна уязвимость
межсайтового исполнения — и токен уходит. Никакие флаги это не лечат.
Cookie с HttpOnly скрипту недоступна. Взамен появляется уязвимость
подделки межсайтового запроса, и она закрывается атрибутом SameSite
и токеном формы.
Обе угрозы реальны, но между ними есть разница в цене: кража токена даёт злоумышленнику доступ, который переживает вкладку, а подделка запроса работает только пока жертва на сайте.
Поэтому: токен в браузере хранится в HttpOnly-cookie. Хранение
в localStorage ради «удобства фронтенда» — это обмен безопасности
на несколько строк кода.
Вердикт
Браузерное приложение с одним бэкендом — сессия в cookie. База уже есть, поход за сессией стоит доли миллисекунды, отзыв работает мгновенно, сложности нет никакой. JWT здесь решает задачу, которой у вас нет, и создаёт задачу отзыва, которой не было.
Сервисы, разговаривающие между собой, — короткий JWT. Здесь преимущество настоящее: общего хранилища сессий между сервисами нет и заводить его не хочется, а срок жизни токена можно сделать минутным, потому что его получает не человек, а процесс.
Несколько доменов или мобильные клиенты — два токена с ротацией. Тот случай, ради которого схема и придумана.
Когда всё это можно упростить. Внутренний инструмент за корпоративным периметром с десятком пользователей переживёт любой из вариантов. Разговор начинается там, где отзыв доступа должен срабатывать быстро и где за это спросят.