lenec ru

← все посты

JWT vs session cookies в 2026: что брать в типичном веб-приложении

10K

Этот спор тлеет уже лет десять. На любой ревьюшке 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 — отличная вещь, просто не для каждого молотка.

Комментарии 0

  • Будьте первым, кто оставит комментарий.

Войдите, чтобы оставить комментарий.