Архитектура auth: session, JWT и refresh rotation — практичный выбор
За последние четыре года я строил auth в двух серьёзных проектах: система управления школой (тысячи учеников, родителей и учителей) и сервис доставки (мобильное приложение курьера + панель диспетчера). В обоих случаях на первой же архитектурной встрече звучал один и тот же вопрос: «Используем session или JWT?» И в обоих случаях правильным ответом было «зависит от ситуации». В этой статье я сравню три основных подхода — серверные сессии, JWT и связку access+refresh с ротацией — с точки зрения реального опыта, а в конце дам конкретные рекомендации, что выбирать в каком случае.
Серверные сессии: старый, но не умерший способ
Классическая схема очень проста. Пользователь логинится, сервер создаёт запись сессии (в Redis или Postgres) и кладёт её ID в httpOnly cookie. С каждым следующим запросом браузер автоматически отправляет cookie, а сервер находит сессию в store и идентифицирует пользователя.
Сильные стороны:
- Отзыв — одна строка кода. Пользователь вышел или сменил пароль — удаляете запись из store, и всё. В школьной системе это нам очень пригодилось: если директор говорит «закройте доступ этому сотруднику прямо сейчас», доступ закрывается за секунду.
- Сервер полностью контролирует ситуацию. Сколько активных сессий, с каких устройств входили — всё видно.
- Браузер сам управляет cookie, на фронтенде не нужно возиться с токенами.
Слабые стороны:
- Обращение к store на каждый запрос — с Redis это микросекунды, но всё же дополнительная зависимость.
- При нескольких серверах нужен общий session store (полагаться на sticky session — обманывать себя).
- Мобильные приложения могут работать с cookie, но это неестественно — в мобильном мире привычнее токены.
JWT: обещание stateless и реальные проблемы
Привлекательность JWT умещается в одно предложение: сервер ничего не хранит, проверяет подпись в токене и узнаёт пользователя. Красиво. Но на практике есть три серьёзные проблемы.
Первая — невозможность отзыва. JWT действует до истечения срока. Пользователь вышел? Токен всё ещё валиден. Пароль украли и мы его сменили? Старый токен продолжает жить. Скажете «сделаем blacklist» — теперь вы проверяете список на каждом запросе, то есть state снова появился, и ваше stateless-преимущество испарилось.
Вторая — кража токена. Токен, утёкший через XSS или попавший в логи, — полноценный ключ до конца срока действия. Если вы поставили срок 7 дней, атакующий 7 дней свободно ходит по вашему API.
Третья — размер. Начнёте запихивать в JWT роли, права и данные профиля — токен разрастётся до 2-3 КБ и будет ездить в заголовке каждого запроса. В сервисе доставки мы допустили именно эту ошибку — приложение курьера на медленном интернете гоняло лишние килобайты с каждым запросом.
Миф «JWT нужен везде»
Скажу прямо: в комбинации «простой монолит + один фронтенд» JWT чаще всего — лишняя сложность. Обоснованных причин выбрать его на самом деле две:
- Микросервисы. Один auth-сервис подписывает токен, а десятки остальных сервисов проверяют подпись публичным ключом, не обращаясь к общему store. Вот здесь stateless действительно ценен.
- API для третьих сторон. Если внешние партнёры ходят в ваш API (потоки OAuth 2.0 / OpenID Connect), нужен стандартный формат токена — это естественная территория JWT.
Если у вас Next.js-фронтенд и один Node.js-бэкенд, аргумент «все же используют JWT» — не архитектурное решение. Я всегда говорю своим ученикам: выбирайте технологию исходя из проблемы, а не из тренда.
Пара access + refresh и ротация
Современная золотая середина — пара из короткоживущего access-токена и долгоживущего refresh-токена. Поток словами выглядит так:
- Пользователь логинится. Сервер выдаёт access-токен на 10-15 минут и refresh-токен на 30 дней.
- Клиент использует access-токен в API-запросах. Сервер проверяет только подпись — в store не ходит.
- Когда access-токен истекает, клиент отправляет refresh-токен на endpoint
/auth/refresh. - Сервер находит refresh-токен в базе, немедленно отзывает старый и возвращает новую пару. Это и есть ротация: каждый refresh-токен работает только один раз.
- Если отозванный (уже использованный) refresh-токен приходит снова — это сигнал reuse detection: токен украден. Сервер отзывает всё семейство токенов (family) этого пользователя и требует повторный логин.
Почему reuse detection так важен? Потому что без ротации украденный refresh-токен тихо работает 30 дней, и вы никогда об этом не узнаете. С ротацией же, кто бы ни использовал старый токен вторым — вор или настоящий пользователь, — система сразу это замечает и выкидывает обоих. В худшем сценарии пользователь один раз логинится заново — это намного дешевле украденного аккаунта.
Где хранить токен: localStorage или httpOnly cookie?
Самая частая ошибка в SPA — класть токен в localStorage. Проблема в том, что localStorage может прочитать любой JavaScript на странице. Одна XSS-уязвимость — кто-то вставил script в комментарий или отравлена зависимость из npm — и токен на сервере атакующего.
httpOnly cookie JavaScript прочитать не может вовсе, украсть токен напрямую через XSS не получится. Взамен приходит риск CSRF: браузер автоматически прикладывает cookie к каждому запросу, а значит форма на чужом сайте может отправить запрос к вашему API от имени пользователя. Хорошая новость — сегодня эта проблема почти решена: атрибут SameSite=Lax или Strict блокирует отправку cookie в cross-site запросах.
Моя практическая рекомендация: храните access- и refresh-токены в cookie с `httpOnly` + `Secure` + `SameSite=Strict`, а path refresh-cookie ограничьте только /auth/refresh. Кража токена через XSS закрывается, от CSRF защищает SameSite.
Код: скелет refresh rotation на Express
Ниже — упрощённый, но рабочий скелет логики ротации и reuse detection. В базе хранится не сам refresh-токен, а только его hash — даже при утечке базы токены будут бесполезны:
import crypto from "node:crypto";
import express from "express";
import cookieParser from "cookie-parser";
const app = express();
app.use(cookieParser());
const sha256 = (v) => crypto.createHash("sha256").update(v).digest("hex");
async function issueRefreshToken(userId, familyId) {
const raw = crypto.randomBytes(32).toString("base64url");
await db.refreshTokens.create({
hash: sha256(raw),
userId,
familyId,
expiresAt: new Date(Date.now() + 30 * 24 * 3600 * 1000),
});
return raw;
}
app.post("/auth/refresh", async (req, res) => {
const raw = req.cookies.refresh_token;
if (!raw) return res.status(401).json({ error: "no_token" });
const record = await db.refreshTokens.findByHash(sha256(raw));
// Reuse detection: отозванный токен пришёл снова —
// значит он украден, закрываем всё семейство
if (!record || record.revokedAt) {
if (record) await db.refreshTokens.revokeFamily(record.familyId);
return res.status(401).json({ error: "reuse_detected" });
}
if (record.expiresAt < new Date()) {
return res.status(401).json({ error: "expired" });
}
// Rotation: отзываем старый и выдаём новый
await db.refreshTokens.revoke(record.id);
const newRaw = await issueRefreshToken(record.userId, record.familyId);
res.cookie("refresh_token", newRaw, {
httpOnly: true,
secure: true,
sameSite: "strict",
path: "/auth/refresh",
});
res.json({ accessToken: signAccessToken(record.userId) }); // 15 минут
});В production к этому добавляются rate limiting, оборачивание findByHash + revoke в одну транзакцию и audit log, но ядро логики — именно такое.
Практические рекомендации: что и когда?
- Простое web-приложение (монолит, server-side render, админка): серверные сессии + Redis. Самый простой и самый контролируемый вариант. Админская часть нашей школьной системы работает именно так и ни разу не подвела.
- SPA + API (React/Vue фронтенд, отдельный бэкенд): пара access + refresh, оба в
httpOnlycookie сSameSite=Strict. Не кладите токены в localStorage. - Мобильное приложение: refresh rotation обязателен. Храните токены в secure storage платформы (Keychain / Keystore). Приложение курьера в сервисе доставки работает именно по этой схеме — access на 15 минут, ротируемый refresh на 30 дней.
- Микросервисы или API для внешних партнёров: вот здесь JWT на своём месте — как короткоживущий access, внутренние сервисы проверяют подпись самостоятельно.
Вывод
В auth-архитектуре нет «самого лучшего» решения, есть контекст. Session — это простота и контроль, JWT — инструмент переноса доверия между сервисами, а refresh rotation — практичный баланс между ними. Два самых важных правила: не храните токен там, где его может прочитать JavaScript, и если используете refresh-токены — не используйте их без ротации. Остальное — инженерное решение, зависящее от масштаба вашего проекта.