open atlas
↑ К треку
NestJS с нуля до senior NEST · 05 · 04

Сессии против JWT, и почему logout — сложно

Сессии хранят авторизацию на сервере и отзывают удалением строки; JWT несёт свои claims и не может быть отозван до exp. Поэтому logout с JWT сложен: очистка cookie ничего не делает с украденным токеном. Митигируй коротким TTL плюс denylist или per-user tokenVersion.

NEST Senior ◷ 18 min
Уровень
ОсновыJuniorMiddleSenior

В тикете было написано: «пользователь травит людей в чате — забань его, сейчас». Дежурный инженер нажал эндпоинт бана, увидел, как строка переключилась в banned: true, и ответил «готово». Через сорок минут абьюз всё ещё шёл. Аккаунт был забанен в базе — и продолжал постить. Никто ничего не отозвал, потому что отзывать было нечего: приложение авторизовало каждый запрос из stateless-JWT, а токен абьюзера был выпущен за двадцать минут до бана с часовым сроком жизни. Флаг бана был настоящим; токену было всё равно. Он продолжал верифицироваться, продолжал проходить guard, продолжал пропускать записи — пока не истёк по своему расписанию, а не по нашему. Logout, как выясняется, — это не действие в UI. С неправильной моделью авторизации это то, что ты сделать не можешь.

Две модели: где живёт состояние авторизации

Прежде чем выбирать модель авторизации или отлаживать logout, который «не сработал», нужно понять одно: где сервер хранит истину о том, кто ты? Ответ на этот вопрос определяет всё остальное — что стоит отзыв, сколько round-trip тратит запрос и возможно ли «забань сейчас» одной строкой. Каждая схема авторизации отвечает на один вопрос на каждом запросе — «этот вызывающий действительно тот, за кого себя выдаёт?» — и две доминирующие модели отвечают на него из противоположных мест.

Stateful серверные сессии держат истину на сервере. Логин создаёт строку сессии в хранилище (Redis или таблица БД); клиент получает назад только непрозрачный, ничего не значащий id сессии в httpOnly cookie. Каждый последующий запрос несёт этот id, и сервер ищет сессию, чтобы узнать, кто ты.

// Stateful: cookie держит непрозрачный id; сервер держит истину.
// Логин пишет строку; каждый запрос читает её обратно.
await store.set(`sess:${sid}`, { userId, roles }, { ttlSec: 3600 });
res.cookie('sid', sid, { httpOnly: true, secure: true, sameSite: 'lax' });

// На каждом запросе guard резолвит личность через ПОИСК:
const session = await store.get(`sess:${req.cookies.sid}`); // 1 чтение store
if (!session) throw new UnauthorizedException();             // отозван → мгновенно вышел

Stateless JWT держит истину в токене. Токен и есть claims (sub, roles, exp), подписанные сервером. Верификация локальна: проверь подпись и exp, доверяй payload. Никакого хранилища, никакого поиска.

// Stateless: токен несёт claims, подписанные. Verify локален — ноль поисков.
const token = jwt.sign({ sub: userId, roles }, SECRET, { expiresIn: '15m' });

// На каждом запросе: проверь подпись + exp, затем доверяй payload. Никакого I/O.
const claims = jwt.verify(req.headers.authorization?.slice(7), SECRET);

Эта разница — весь урок. Сессии стоят одного чтения store на запрос и заставляют делить это хранилище между всеми инстансами приложения (sticky-сессии или центральный Redis — общее состояние и есть цена). JWT стоит ноля поисков и масштабируется горизонтально без чего-либо общего — и именно поэтому его нельзя отозвать: нет строки, которую можно удалить.

Проблема logout: тривиальна с сессиями, сложна с JWT

«Разлогинь этого пользователя везде» / «забань этот аккаунт СЕЙЧАС» — это одна и та же операция, и она брутально обнажает асимметрию.

С сессиями это одна строка: удали строку. Поиск следующего запроса промахнётся, guard выбросит, и пользователь вышел — на каждом устройстве, мгновенно, глобально. Сервер держал состояние, значит сервер может его уничтожить.

// Сессии: отзыв ЕСТЬ удаление. Мгновенно, глобально, готово.
await store.del(`sess:${sid}`);        // одно устройство
await store.del(`user-sessions:${userId}`); // или каждое устройство — следующий поиск промахнётся

С JWT такой строки нет. Токен само-валидируется: любой держатель байтов авторизован до exp, точка. Ты можешь очистить cookie — но это затронет только кооперирующего клиента. Токен, скопированный вором или переигранный скриптом абьюзера, нетронут. Нет серверной рукоятки, за которую можно потянуть.

Поэтому ты симулируешь отзыв, и каждый вариант — это компромисс:

// (1) КОРОТКИЙ TTL access-токена — сожми неотзываемое окно до минут.
//     Ради этого существуют refresh-токены (разбирается отдельно): отзывает сам exp.
jwt.sign({ sub: userId }, SECRET, { expiresIn: '15m' }); // окно ≤ 15 минут

// (2) DENYLIST отозванных jti до их exp — но это снова поиск на каждом запросе,
//     возвращающий общее состояние, ради ухода от которого ты взял JWT. Гибрид.
if (await denylist.has(claims.jti)) throw new UnauthorizedException(); // +1 чтение

// (3) PER-USER tokenVersion (он же minIssuedAt) — бампай его на logout-all /
//     смене пароля; отвергай любой токен, выпущенный раньше. Один индексированный поиск,
//     инвалидирует ВСЕ токены пользователя разом. Прагматичная золотая середина.
const u = await users.findById(claims.sub);               // 1 индексированное чтение
if (claims.tokenVersion !== u.tokenVersion) throw new UnauthorizedException();

Вариант (1) ограничивает окно урона, но никогда не закрывает его по требованию. Вариант (2) даёт точный отзыв на каждый токен, но возвращает чтение на запрос, ради ухода от которого ты ушёл в stateless. Вариант (3) — тот, на котором оседает большинство команд: единственная индексированная колонка, бампнутая один раз, убивает каждый токен пользователя — правильный инструмент для «забань сейчас» и «разлогинь все устройства».

Почему это работает

Почему «просто удали cookie при logout» на самом деле не разлогинивает JWT-пользователя? Потому что JWT само-валидируется — он несёт свою подпись, и сервер доверяет ему без поиска. Любая сторона, держащая сырые байты, авторизована до exp: легитимный браузер, конечно, но также вор, скопировавший токен, или скрипт, переигрывающий его. Очистка cookie убирает лишь копию в кооперирующем браузере; она не может дотянуться до украденной или переигранной копии, потому что сервер не хранит записи о выпущенных токенах, которую можно было бы инвалидировать. Очистка cookie — это любезность на стороне клиента, а не отзыв. Настоящий отзыв требует серверного состояния, которое сервер может изменить — записи в denylist или бампнутого tokenVersion, — так чтобы следующий verify смог отвергнуть токен, который подпись иначе бы приняла.

Где какая модель уместна — и почему реальные системы гибридны

Тянись к сессиям, когда мгновенный глобальный отзыв — жёсткое требование, а общее хранилище приемлемо: классические server-rendered веб-приложения, банкинг, админ-консоли — везде, где «убей этот доступ прямо сейчас» должно стать правдой в следующую миллисекунду. Тянись к JWT, когда центральное хранилище сессий — это узкое место: stateless-микросервисы, мобильные/API-клиенты и межсервисная авторизация, где каждый хоп, перепроверяющий один Redis, сериализовал бы твой fan-out.

Большинство зрелых систем отказываются от бинарности и идут в гибрид: короткоживущий stateless access-JWT (ноль поисков на горячем пути) в паре со stateful refresh-токеном, хранимым в БД (отзываемым). Ты уже держишь серверное состояние на границе refresh, так что отзыв refresh-токена останавливает выпуск новых access-токенов — а короткий access-TTL осушает те, что уже выданы. Это и есть архитектура, дающая масштаб JWT и отзываемость сессий, с неотзываемым окном, ограниченным access-TTL.

Несколько JWT-граблей едут с тобой в любом случае. Размер токена: JWT в заголовке или cookie куда крупнее непрозрачного id сессии и едет на каждом запросе — держи claims минимальными, никогда не запихивай весь профиль пользователя. Расхождение часов: exp сравнивается с часами каждого сервера, так что допусти небольшой leeway (несколько секунд), иначе нода с убежавшими часами отвергнет ещё валидные токены. Флаги cookie важны для обеих моделей: httpOnly не даёт JS читать токен, Secure держит его вне открытого текста, SameSite притупляет CSRF.

Выбери лучший вариант

Админ должен мгновенно забанить сейчас залогиненного пользователя на всех его устройствах. Приложение авторизует каждый запрос stateless access-JWT (TTL 15 минут). Какой подход лучший?

Викторина

Какая модель авторизации даёт мгновенный глобальный отзыв залогиненного пользователя и почему?

Викторина

Твой API использует stateless access-JWT. Ты должен принудительно разлогинить пользователя до exp его токена. Какой подход работает и почему наивный проваливается?

Вспомните перед уходом
  1. 01
    Сопоставь stateful-сессии и stateless-JWT по тому, где живёт состояние авторизации, по цене на запрос и по отзыву.
  2. 02
    Почему очистка cookie не разлогинивает JWT-пользователя, и каковы три реальные митигации с их компромиссами?
Итог

Каждая модель авторизации отвечает на «этот вызывающий действительно тот, за кого себя выдаёт?» из одного из двух мест, и этот выбор решает, возможен ли вообще logout. Stateful-сессии держат истину на сервере: логин пишет строку сессии в Redis или БД, клиент держит лишь непрозрачный id в httpOnly cookie, и каждый запрос стоит одного чтения store для резолва личности — плюс общее хранилище между инстансами. Награда в том, что отзыв есть удаление: дропни строку, и следующий поиск промахнётся, так что пользователь вышел мгновенно и глобально. Stateless-JWT держит истину в токене — самих подписанных claims, — так что верификация локальна с нулём поисков и без общего состояния, что масштабируется горизонтально, но означает, что нет строки для удаления: JWT валиден до своего exp что бы ни случилось, а очистка cookie убирает лишь копию кооперирующего клиента, никогда — украденную или переигранную. Поэтому забаненный пользователь продолжал постить. Чтобы force-logout JWT, ты возвращаешь серверное состояние с компромиссом: короткий access-TTL (~5–15 мин) ограничивает окно, denylist отозванных jti даёт точность на каждый токен ценой чтения на запрос, а per-user tokenVersion (колонка, хранящая версию токенов пользователя), бампнутый на logout-all, убивает каждый токен пользователя одним индексированным поиском. Сессии подходят server-rendered приложениям, банкингу и админке, где мгновенный отзыв не обсуждается; JWT подходит stateless-микросервисам, мобайлу/API и межсервисной авторизации, где центральное хранилище — узкое место; а большинство зрелых систем гибридны — stateless access-JWT в паре со stateful, отзываемым refresh-токеном в БД, отзывая на границе refresh, пока короткий access-TTL осушает остальное. Держи claims минимальными (размер едет на каждом запросе), допускай небольшой clock-skew leeway против exp и ставь httpOnly + Secure + SameSite в обеих моделях. Теперь, когда кто-то скажет «почему забаненный пользователь всё ещё постит?», первый вопрос будет: какой TTL access-токена, есть ли колонка tokenVersion и кто отозвал refresh? Это и есть верная ментальная модель — не «мы же очистили cookie?»

Практика

Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.

вспомнитьприменитьуглубить0 из 5 завершено

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources2
expand
  1. 01
  2. 02

Trademarks belong to their respective owners. Editorial reference only.