Сессии против токенов
Серверные сессии — это состояние и мгновенный отзыв; bearer-токены — без состояния и быстрые, но живут до истечения. Настоящий компромисс — задержка отзыва против стоимости lookup, и где каждый ломается.
Пользователь нажимает «выйти со всех устройств» после того, как потерял ноутбук. С серверными сессиями вы выполняете один DELETE FROM sessions WHERE user_id = ? — и каждая украденная кука мертва на следующем запросе. С JWT access-токенами, которые ваша команда выкатила в прошлом квартале, вы жмёте кнопку, получаете зелёный тост — а токен атакующего работает ещё четырнадцать минут, потому что на сервере ничто фактически не проверяет, нужен ли этот токен. Подпись валидна, срок не истёк — и ваш API его пропускает. Эта дыра между «я отозвал» и «оно перестало работать» и есть вся разница между двумя подходами; именно её тихо пропускают на демо и обнаруживают во время инцидента.
К концу урока вы будете точно знать, что вы отдаёте, выбирая stateless bearer-токены вместо серверных сессий, почему отзыв — это стержень, и где каждый подход ломается в продакшене.
Два способа помнить вошедшего пользователя
После того как пользователь доказал, кто он, сервер должен помнить этот факт на каждом следующем запросе, потому что сам HTTP не имеет состояния — каждый запрос приходит без памяти о предыдущем. Есть два доминирующих способа нести этот факт «вы вошли», и они стоят на противоположных концах одной оси: где живёт истина.
Серверная сессия держит истину на сервере. При входе сервер создаёт запись — session_id → {user_id, created_at, roles, …} — в хранилище (Postgres, Redis, подписанное зашифрованное cookie-хранилище) и выдаёт клиенту непрозрачный случайный session_id в куке. Кука — это лишь ключ для поиска; сама по себе она ничего не значит. На каждом запросе сервер берёт этот id, находит запись, и именно эта запись — источник истины. Это stateful (с состоянием): сервер держит состояние сессии и обращается к нему на каждом вызове.
Bearer-токен (JWT — частая форма) держит истину внутри токена. При входе сервер собирает небольшой JSON-payload — {sub: user_id, roles, exp} — и подписывает его секретом. Клиент хранит токен и шлёт его на каждом запросе, обычно как Authorization: Bearer <token>. Сервер проверяет подпись тем же секретом и верит заявленным внутри claims — без обращения к БД, без общего хранилища. Это stateless (без состояния): любой сервер с ключом проверки валидирует токен в одиночку. «Bearer» — буквально: владение и есть авторизация. Кто держит токен, того и считают пользователем, ровно как наличные.
Настоящий компромисс: задержка отзыва против стоимости lookup
Токены продают как «масштабируемые, без обращения к БД на запрос», а сессии — как «старые и медленные». Эта подача прячет реальное решение. Честный компромисс — это обмен одной стоимости на другую.
Сессии покупают мгновенный отзыв, расплачиваясь lookup на каждом запросе. Поскольку сервер читает запись сессии на каждом вызове, вы можете изменить или убить её в любой момент: удалить строку для принудительного выхода, переключить поле roles, чтобы понизить пользователя посреди сессии, или инвалидировать все сессии аккаунта после сброса пароля. Цена — этот lookup (Redis GET — субмиллисекунда, чтение Postgres — дольше), и хранилище теперь становится зависимостью на горячем пути каждого аутентифицированного запроса.
Токены покупают stateless горячий путь, расплачиваясь отзывом, которого у вас нет. Нет обращения к хранилищу на запрос, поэтому любой узел с ключом валидирует независимо — это реально помогает флоту stateless-сервисов и кросс-доменным API. Но то же свойство, что убирает lookup, убирает и место сказать «стоп». Подписанный токен валиден, пока не пройдёт его exp; у сервера нет записи, которую можно удалить. Поэтому в момент, когда нужен настоящий отзыв — выход со всех устройств, украденный токен, экстренное понижение прав, — вам приходится прикручивать состояние обратно (denylist, схема short-lived-access плюс refresh), и тут вы снова платите за lookup, а «stateless»-преимущество, ради которого вы это брали, частично исчезло.
Вот зрелая формулировка: вы выбираете не быстро против медленно, а то, где живёт задержка отзыва. Сессии ставят её в ноль и берут плату lookup’ом. Чистые токены выталкивают её на время жизни токена и берут плату инцидентом, когда это окно неверно.
▸Частая ошибка
Самая частая продакшен-ошибка — выкатить долгоживущие JWT (exp в часах или днях), будто это сессии, а потом во время инцидента обнаружить, что «выйти» и «забанить пользователя» на самом деле не работают. Смягчение, делающее токены отзываемыми, — короткоживущий access-токен (5–15 мин) в паре с refresh-токеном, хранимым на сервере. Access-токен остаётся stateless и быстро умирает; refresh-токен — это stateful, отзываемая часть: отозвав его, вы останавливаете новые access-токены, а максимум, сколько проживёт уже выданный access-токен, — его короткий срок. Вы не сбежали от состояния; вы сжали окно, которое оно должно покрыть, до нескольких минут.
Где ломается каждый
У каждого подхода есть фирменный сбой, и знание их — это то, как вы выбираете.
Сессии ломаются на фиксации сессии и на хранилище. Фиксация сессии: если сервер принимает session id, подсунутый атакующим (например, из крафченой ссылки), а затем повышает этот же id до аутентифицированного при входе, то атакующий — который id уже знает — теперь вошёл как жертва. Фикс не обсуждается и держится на одной строке дисциплины: регенерируйте session id при каждой смене привилегий, особенно при входе, чтобы доаутентификационный id никогда не перетёк в аутентифицированную сессию. Сессии также делают хранилище жёсткой зависимостью: если Redis лежит, никто не аутентифицирован, поэтому ему нужна та же доступность, что и приложению.
Токены ломаются на отзыве и на хранении. Отзыв: разобрано выше — утёкший токен годен до exp, а «выход» — ложь, пока вы не добавили denylist или короткий срок. Хранение: bearer-токен — это наличные, поэтому то, где браузер его держит, решает радиус поражения. В localStorage он читаем любым JavaScript на странице, поэтому одна XSS его выносит; в HttpOnly-куке он невидим для JS (XSS не может его прочитать), но теперь ездит автоматически, и вы снова внесли экспозицию CSRF, которую надо парировать SameSite и анти-CSRF-токенами. Нет места хранения без компромиссов — есть лишь то, чей компромисс вы осознанно обработали.
| Измерение | Серверная сессия | Bearer-токен (JWT) |
|---|---|---|
| Источник истины | Серверное хранилище (кука = непрозрачный ключ) | Сам подписанный токен (claims) |
| Состояние на горячем пути | Lookup на каждом запросе (stateful) | Проверка подписи, без lookup (stateless) |
| Отзыв | Мгновенный — удалить запись | Нет до exp, пока не добавите denylist |
| Форма масштабирования | Нужно общее/доступное хранилище | Любой узел с ключом валидирует один |
| Фирменный сбой | Фиксация сессии; падение хранилища = нет аутентификации | Утёкший токен годен до exp; хранение XSS/CSRF |
Как зрелый инженер реально решает
По умолчанию — серверные сессии для first-party веб-приложений: один origin, браузер, который и так хорошо умеет куки, и жёсткое требование, чтобы «выйти» и «забанить» работали сейчас. Lookup на запрос против Redis дёшев и покупает отзыв, который понадобится в день, когда что-то утечёт. Берите токены, когда форма требует statelessness — вызовы сервис-сервис, мобильные и сторонние API-клиенты, или флот, куда не вписывается общее хранилище сессий, — и когда берёте, делайте их короткоживущими access-токенами с серверным refresh, чтобы stateless-часть была ограничена минутами, а отзываемая часть жила в refresh-токене. Неверный ответ — середина: долгоживущий JWT в роли де-факто сессии, который одновременно даёт вам проблему отзыва токена и радиус поражения сессии.
First-party веб-приложение должно поддерживать «выход со всех устройств» и мгновенный бан при злоупотреблении. Аутентификация работает против Redis, который вы уже эксплуатируете. Выберите дизайн.
Коллега говорит: «JWT безопаснее сессий, потому что на сервере ничего не хранится». В чём точная поправка?
Почему приложение на сессиях обязано регенерировать session id при входе?
Упорядочьте шаги, делающие stateless access-токен реально отзываемым в продакшене, от выдачи до принудительного выхода:
- 1 Выдать короткоживущий access-токен (5–15 мин) плюс refresh-токен, хранимый на сервере
- 2 Клиент зовёт API с access-токеном — stateless, без lookup, пока тот не истечёт
- 3 При выходе/бане отозвать refresh-токен в серверном хранилище
- 4 Истёкший access-токен нельзя обновить, поэтому доступ кончается в пределах короткого окна exp
- 01Объясните настоящий компромисс между серверными сессиями и bearer-токенами — не «быстро против медленно», а что на самом деле обменивается.
- 02Где каждый подход ломается в продакшене и как смягчить каждый сбой?
HTTP забывает вас между запросами, поэтому серверу нужен способ нести «этот пользователь вошёл» — и их два, разделённых тем, где живёт истина. Серверные сессии держат её на сервере: кука — непрозрачный ключ, запись в хранилище — истина, и поскольку сервер читает её на каждом запросе, вы получаете мгновенный отзыв — удалите запись, и следующий вызов отклонён. Bearer-токены (JWT) держат истину в подписанном payload, который несёт клиент; сервер проверяет и верит ему без lookup, что stateless и отлично для флотов и сторонних, но означает, что подписанный токен валиден до exp, и удалять нечего. Настоящий компромисс — это задержка отзыва против стоимости lookup, а не скорость: сессии ставят отзыв в ноль и платят зависимостью от хранилища; чистые токены выталкивают его на время жизни токена. Каждый ломается по-своему — сессии на фиксации (регенерируйте id при входе) и падении хранилища; токены на отзыве (короткий access плюс серверный refresh) и на том, где браузер их хранит (localStorage против HttpOnly, XSS против CSRF). Зрелый дефолт — сессии для first-party веб-приложений и короткоживущие access-плюс-refresh токены, когда форма действительно требует statelessness, — и никогда долгоживущий JWT, притворяющийся сессией.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.
Примени это
Примени этот урок в реальном проекте.