JWT vs session cookies в 2026: что брать в типичном веб-приложении
Этот спор тлеет уже лет десять. На любой ревьюшке pull-request с авторизацией обязательно встречается «давай JWT, чтобы stateless» против «нет, лучше серверные сессии». Я сидел по обе стороны и хочу разложить, чем эти подходы отличаются на практике, и в каких ситуациях я что выбираю в 2026-м.
Что значат термины
Чтобы разговор был ровный, договоримся о понятиях.
- Session cookie — кука с идентификатором сессии. На сервере хранится строка «sessionId → userId, expiresAt». На каждый запрос сервер ходит в свою БД и достаёт сессию.
- JWT — токен формата
header.payload.signature, в котором payload — это сами данные сессии (id пользователя, имя, роль, exp). Подпись валидируется публичным ключом или секретом. На сервере, в базовом виде, никакого state не хранится.
Технически и тот и другой могут лежать в куке. Разница в том, что JWT хранит данные сам, а session cookie — это просто ключ к серверной записи.
Когда хорош session cookie
Это мой default. Серверная сессия выручает в большинстве сценариев типового веб-приложения, и вот почему:
1. Отзыв сессии
Пользователь нажал «Выйти из всех устройств» — мы делаем DELETE по сессиям этого userId. На следующем запросе любого устройства cookie уже не валиден.
С JWT эта операция требует отдельной revoke list (denylist) или хранения версии в БД, против которой токен сверяется. То есть к stateless-протоколу мы пристёгиваем state, и весь смысл «без БД» испаряется.
2. Изменение прав
Понизили пользователя из админа в обычного юзера. Серверная сессия не несёт роль внутри куки — мы просто на следующем запросе достанем актуальную роль из БД. С JWT роль зашита в токен. Пока он не истечёт, старая роль работает. Лечится короткими сроками жизни и refresh-флоу, но это лишний слой.
3. Размер запроса
Session cookie — это 30–40 байт. JWT с парой полей — 400–700 байт. Это не катастрофа, но на каждом запросе с куками ездит лишнее. Особенно заметно на медленных мобильных сетях.
4. Безопасность по умолчанию
Session cookie ставится с HttpOnly, Secure, SameSite=Lax. JS на странице к ней не подбирается — XSS не утащит. У JWT часто берут моду класть в localStorage «чтобы было удобно». Это типичный антипаттерн: любой XSS уносит токен. JWT в куке — нормально, JWT в localStorage — нет.
Когда хорош JWT
Бывают сценарии, в которых JWT действительно сильнее.
1. Между сервисами
Если у тебя API gateway и несколько микросервисов, и все они должны проверять идентичность пользователя — JWT с подписью отличный вариант. Каждый сервис самостоятельно проверяет подпись по публичному ключу, не ходит в общий auth-сервис на каждый запрос. Это как раз про stateless.
2. Короткоживущие токены доступа
OAuth-flow выдаёт access token, обычно JWT, на 5–60 минут. Это разумно: токен короткий, передаётся между фронтом и API-провайдером, фронт не хранит на нём ничего важного. Refresh-token — отдельная история и часто уже не JWT.
3. Mobile/desktop клиенты, у которых нет cookie-инфраструктуры
Нативные приложения часто легче работают с заголовком Authorization: Bearer ..., чем с куками. Тут JWT удобен, потому что хорошо ложится в этот формат. Но и тут access+refresh, а не один долгоживущий токен.
4. Edge runtime без БД
На Cloudflare Workers/Vercel Edge в простом сценарии («дай страницу залогиненному пользователю») может не быть желания тащить общую БД. JWT с подписью проверяется без сетевого вызова, и это иногда выручает. Чем платить — отзывом и размером токена.
Анти-паттерны, которые я встречаю чаще всего
JWT в localStorage
Уже сказал, повторю. Никаких токенов в localStorage. Любая XSS-уязвимость превращается в кражу аккаунтов. Кладёшь токен в куку с правильными флагами — становится в разы спокойнее.
JWT с долгим сроком жизни как сессия
«Дам JWT на 30 дней, не буду возиться с сессиями». Получается конструкция, которую невозможно отозвать. Сменил пароль — старый токен живёт. Уволил сотрудника — продолжает ходить. Если хочешь отзыв — нужна denylist; если denylist — это уже sessions с лишним шагом.
Refresh-токен в localStorage
Тот же anti-pattern, только refresh-токен живёт месяцами. Если уж хранишь refresh — кука HttpOnly и не иначе.
Подпись секретом, который оказался в фронтенд-бандле
Видел дважды. Кто-то один раз положил JWT_SECRET в process.env, и сборщик его в фронт зашил. Подпись HS256 проверяется тем же секретом, что и подписывает: с того момента любой клиент может выпускать валидные токены от лица сервера. Лечится правилом «секреты только в server-side файлах» и проверкой бандла после билда.
Что я выбираю в 2026
Базовый сценарий: пользователь логинится в веб-приложение, ходит по нему, иногда по API. Я беру session cookies, серверные сессии в БД. Никаких JWT.
Если у меня многосерверная архитектура и нужно проверять идентичность между сервисами — я беру session cookies снаружи, и service-to-service JWT внутри, который выписывает один auth-сервис.
Если у меня OAuth provider и я выдаю наружу access/refresh токены — это, конечно, JWT с коротким сроком жизни.
Никогда не делаю «универсальный» JWT, который и хранится месяц, и проверяется на фронте, и используется как сессия. Это объединение худшего из двух миров.
Реализация в Node на Better Auth
Better Auth по умолчанию делает session cookies в БД. Конфигурируется довольно банально:
export const auth = betterAuth({
database: drizzleAdapter(db, { provider: 'pg' }),
emailAndPassword: { enabled: true },
session: {
expiresIn: 60 * 60 * 24 * 30,
updateAge: 60 * 60 * 24,
cookieCache: { enabled: true, maxAge: 60 },
},
advanced: {
cookiePrefix: 'app',
useSecureCookies: process.env.NODE_ENV === 'production',
},
});Что заметно: cookieCache — лёгкий on-server LRU перед обращением к БД. Снимает нагрузку с базы при множестве запросов в коротком окне, но не отменяет проверку: при session.expiresIn и updateAge данные обновляются.
Когда захочется JWT внутри Better Auth — есть плагин
import { jwt } from 'better-auth/plugins';
export const auth = betterAuth({
// ...
plugins: [jwt({ jwks: { keyPairConfig: { alg: 'EdDSA' } } })],
});Плагин выдаёт JWT-токен по запросу /api/auth/token, выставляет JWKS endpoint /api/auth/jwks. Это удобно, когда нужен токен для service-to-service: основной login по-прежнему через сессии, а JWT генерится по требованию для backend-вызовов.
Чек-лист безопасности
- Cookies:
HttpOnly,Secure,SameSite=Lax. На редких сценариях с iframe —SameSite=None; Secure. - Никаких токенов в localStorage. Никогда.
- На любые auth-эндпоинты — rate limiting.
- На sign-in — защита от brute force: счётчик неудачных попыток на email или IP.
- Серверные сессии чистятся регулярно: cron на удаление просроченных или индекс с TTL.
- Отзыв сессий по событиям: смена пароля, выход из всех устройств, подозрительная активность.
- Если выпускаешь JWT — короткий срок жизни (5–15 минут для access), refresh в куке HttpOnly с rotation.
Главный поинт. Stateless-аутентификация в монолитном веб-приложении — почти всегда выдуманная задача. У тебя уже есть БД, ходишь в неё на каждом запросе всё равно. Один лишний select по индексу не сделает приложение медленнее, зато даст возможность отзыва, актуальных прав и компактных запросов. JWT — отличная вещь, просто не для каждого молотка.