Сессии против JWT: отзыв, ротация и где живёт состояние на serverless
Непрозрачные id сессий в HttpOnly-cookie с префиксом __Host- плюс хранилище KV/БД дают мгновенный отзыв ценой ~1-2 мс на запрос; JWT проверяется локально, но не отзывается до exp. Ротация, атрибуты cookie и место хранилища на serverless решают, чем вы сможете управлять.
Финтех-команда уверенно идёт к SOC 2, пока аудитор не задаёт один вопрос: «В 14:00 украден ноутбук с активной сессией. Покажите, как вы её завершаете». Они не могут. Аутентификация — JWT с 24-часовым exp, проверяемый только подписью: ни один запрос не спрашивает сервер, по-прежнему ли токен желанный гость. Кнопка поддержки «выйти на всех устройствах» удаляет refresh-токены, так что вор не выпустит новый токен — но тот, что на украденном ноутбуке, действителен до завтра, потому что это и значит «stateless»: сервер не хранил состояния, которое можно было бы удалить. Команда выкатывает очевидную заплатку — access-токены теперь живут 5 минут, refresh ходит через базу, — и на ретро инженер задаёт неудобный вопрос: если каждый клиент и так бьёт в базу каждые пять минут, что именно нам покупает эта безсостоятельность? Этот вопрос — весь урок.
Сессия — указатель; JWT — копия
Прежде чем выбирать стратегию аутентификации, задайте себе один вопрос: что произойдёт, если нужно прекратить активную сессию прямо сейчас, а не через час? Именно этот ответ разделяет сессии и JWT чище любого бенчмарка производительности.
Stateful-сессия не хранит в браузере ничего осмысленного. Cookie несёт непрозрачный случайный токен — минимум 128 бит энтропии, на практике 256 из crypto.randomBytes(32), — а сервер держит запись, отображающую этот токен в id пользователя, срок и метаданные. Каждый аутентифицированный запрос делает один поиск в хранилище. Выигрыш — операционный контроль: отзыв — это удаление строки, действующее уже на следующем запросе. «Выйти везде» — удаление всех строк пользователя. Ротация — выпуск свежего токена с инвалидацией старого — происходит при логине и при повышении привилегий; это стандартная защита от фиксации сессии: атакующий, подбросивший токен до логина, после него держит указатель в пустоту.
JWT переворачивает схему: утверждения едут вместе с запросом — header.payload.signature, — а проверка локальна: сверить подпись, сверить exp, готово. Ни хранилища, ни поиска, ни общей инфраструктуры между издателем и проверяющим. Цена симметрична: сервер не хранил состояния — значит, и удалять нечего. Украденный JWT действителен до exp, что бы вы ни делали, если только не проверять каждый запрос по серверному денлисту — а это значит заново построить хранилище сессий, оставив сверху ещё и парсинг JWT. Честная stateless-мера — короткоживущие access-токены (5–15 минут) плюс refresh-токен, который проверяется по базе, с ротацией и детекцией повторного использования: окно отзыва сжимается до TTL access-токена вместо того, чтобы исчезнуть.
// app/lib/session.ts
import { cookies } from 'next/headers';
import { randomBytes } from 'node:crypto';
import { kv } from '~/lib/kv';
export async function createSession(userId: string) {
const token = randomBytes(32).toString('hex'); // 256 бит — неугадываемый и бессмысленный
const maxAge = 60 * 60 * 24 * 7;
await kv.set(`session:${token}`, { userId, createdAt: Date.now() }, { ex: maxAge });
(await cookies()).set('__Host-session', token, {
httpOnly: true, // JS никогда его не прочитает — XSS не утащит cookie
secure: true, // требуется префиксом __Host-
sameSite: 'lax', // межсайтовые POST-ы его не несут
path: '/', // требуется префиксом __Host-
maxAge,
});
}
export async function revokeSession(token: string) {
await kv.del(`session:${token}`); // мёртв на следующем запросе — в этом весь смысл
}Финтех-SaaS обслуживает только браузерных клиентов. Политика безопасности требует завершения любой активной сессии в течение 60 секунд после команды администратора. Сервис работает на serverless (Vercel) с уже используемой базой Postgres. Какая стратегия аутентификации подходит?
Атрибуты cookie — половина модели безопасности
Когда вы смотрите на сессионный cookie в DevTools браузера, вы читаете контракт безопасности, записанный четырьмя атрибутами. Пропустите один — и в этом контракте появляется дыра, в которую войдёт атакующий.
Какой бы токен вы ни выбрали, в браузере он должен жить в cookie с полным набором атрибутов, потому что каждый атрибут закрывает реальную атаку. HttpOnly делает cookie невидимым для JavaScript — XSS-нагрузка не сможет его эксфильтровать (честно про предел: XSS всё ещё может действовать от имени пользователя, стреляя same-origin-запросами; HttpOnly останавливает кражу, а не злоупотребление). Secure не пускает его в открытый HTTP. SameSite=Lax — умолчание, которое стоит сохранить, — означает, что межсайтовые POST-ы cookie не несут вовсе, и это ваш первый слой защиты от CSRF; его отправляют только top-level GET-навигации, поэтому GET-обработчик, мутирующий состояние, — самострел в CSRF-дыру.
Недооценённый атрибут — префикс __Host-. Cookie с именем __Host-session браузер примет только если он Secure, с path=/ и без атрибута Domain — то есть привязан ровно к одному хосту. Сравните с легаси-паттерном Domain=.example.com: теперь любой поддомен может читать и, хуже, устанавливать этот cookie. Один скомпрометированный status.example.com — заброшенный маркетинговый микросайт, уязвимый сторонний инструмент на поддомене — может подбросить сессионный cookie на родительский домен (cookie tossing — подброс cookie с поддомена на родительский домен) и завести жертву в сессию, контролируемую атакующим. Префикс __Host- выключает весь этот класс атак соглашением об имени, которое браузер навязывает сам.
Аутентификация — 24-часовые JWT, проверяемые только подписью. В 14:00 безопасник нажимает «отозвать все сессии» скомпрометированного пользователя, что удаляет его refresh-токены. Каково реальное оставшееся окно доступа атакующего?
Где живёт хранилище на serverless
Сессиям нужно хранилище, и на serverless им не может быть память процесса. Map на уровне модуля безупречно работает в next dev — один долгоживущий процесс — а в проде порождает баги-призраки: у каждого инстанса функции своя память, инстансы работают параллельно и перерабатываются без предупреждения. Логин пишет сессию в инстанс A; следующий запрос попадает в инстанс B и выглядит разлогиненным; деплой стирает всё. Хранилище должно быть внешним, и у реалистичных вариантов есть честные числа. Регионально-локальный Redis/KV (Upstash, Vercel KV) отвечает примерно за 1–2 мс p50 — самый дешёвый налог на аутентифицированный запрос, хотя p99 у KV поверх HTTP растягивается до 10+ мс. Таблица sessions в Postgres стоит 5–15 мс и добавляет давление на пул соединений из функций, зато даёт транзакции и на одну систему меньше. Сессии в зашифрованном cookie (паттерн iron-session) не требуют хранилища вовсе — и имеют ровно JWT-проблему отзыва с другим кодеком: сервер опять не хранит ничего, что мог бы удалить.
Против этого — HMAC-проверка JWT: десятки микросекунд CPU и ноль IO. Разрыв — ~1 мс против ~0,05 мс — реален, но редко решает для дашборда; он решает, когда проверка происходит на CDN-edge для каждого запроса или между сервисами, не способными разделить хранилище. Гибрид, к которому сходится большинство команд: короткоживущий JWT access-токен (5–15 мин), проверяемый локально, refresh-токен — в хранилище. Окно отзыва равно TTL access-токена — впишите это предложение в свои security-доки: именно это число спросит аудитор. Если требование — «прекратить доступ за 60 секунд», ваш access-токен живёт ≤60 секунд, и вы всё равно бьёте в хранилище каждую минуту — а тогда обычные сессии оказываются более простой системой с той же ценой.
▸Почему это работает
Почему доки Next.js по аутентификации и OWASP оба ведут браузерную аутентификацию к непрозрачным серверным сессиям, хотя в туториалах громче звучит JWT? Потому что JWT придуман для задачи другой формы: короткоживущая делегация сервис-сервис, где издатель и проверяющий — разные системы, которые не могут разделить хранилище сессий, а токены истекают за минуты, и отзыв почти не важен. Браузерная сессия — противоположная форма: один издатель, один проверяющий (ваше приложение), долгие сроки жизни и жёсткое операционное требование убивать доступ по требованию — украденные ноутбуки, уволенные сотрудники, скомпрометированные аккаунты. Безсостоятельность решает проблему распределённости, которой у браузерных приложений почти нет, и создаёт проблему отзыва, которая у них очень даже есть.
Команда держит сессии в Map на уровне модуля в Next.js-приложении, задеплоенном на serverless-функции. В разработке всё работает. Что произойдёт в проде?
- 01Почему обычный JWT нельзя отозвать и какова честная мера с её точной ценой?
- 02От чего защищает каждый атрибут сессионного cookie и что добавляет префикс __Host-?
Stateful-сессия кладёт в cookie непрозрачный случайный токен (256 бит из crypto.randomBytes), а правду — в серверное хранилище: каждый аутентифицированный запрос платит один поиск, и взамен отзыв — это удаление строки, действующее немедленно, «выйти везде» — удаление всех строк пользователя, а ротация при логине и повышении привилегий закрывает фиксацию сессии. JWT везёт подписанные утверждения с запросом и проверяется локально за десятки микросекунд без IO — именно поэтому его нельзя вернуть: сервер не хранил состояния, украденный токен действителен до exp, а проверка денлиста на каждом запросе просто заново строит хранилище сессий. Реалистичный stateless-компромисс — короткоживущий access-токен плюс проверяемый по хранилищу refresh-токен с ротацией и детекцией повторного использования, и его свойство безопасности укладывается в одно предложение: окно отзыва равно TTL access-токена. На стороне браузера атрибуты cookie — половина модели: HttpOnly против эксфильтрации (не против same-origin-злоупотребления), Secure, SameSite=Lax как первый слой против CSRF со следствием, что GET-обработчики никогда не мутируют, и префикс __Host-, прибивающий cookie к хосту и закрывающий cookie tossing с поддоменов, который приглашает Domain=.example.com. На serverless хранилищем не может быть память процесса — модульные Map проходят dev и smoke-тесты, а потом вероятностно разлогинивают людей между инстансами, — так что это регионально-локальный KV за ~1-2 мс p50, Postgres за 5-15 мс с давлением на пул, или сессии в зашифрованном cookie, тихо импортирующие обратно JWT-проблему отзыва. Теперь, когда коллега скажет «давайте JWT — не нужно хранилище», вы знаете встречный вопрос: каково ваше окно отзыва и укладывается ли в него ваша политика безопасности?
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.
Примени это
Примени этот урок в реальном проекте.