Разбираем сессии и JWT: где хранится правда, как работает отзыв доступа и что выбрать для вашего API. Практические советы и код.
Каждое приложение на каждый запрос отвечает на один и тот же вопрос: «Кто это и можно ли ему это делать?». Два популярных ответа — сессии и JWT. Вокруг них много хайпа, но выбор часто делают неправильно. В этой статье разберем, чем они отличаются на самом деле, и как не наступить на грабли, которые ломают продакшн.
Вы логинитесь. Сервер проверяет пароль и, если всё ок, записывает строку в хранилище. В этой строке — ваш user id, срок действия, возможно роли. Хранится она в Redis, Postgres или в памяти, если вы смелые. Сервер отправляет вам cookie с одним-единственным значением — случайным id. Это не ваша личность. Это номерок из гардероба.
На каждом следующем запросе сервер берёт ваш session id, идёт в хранилище и спрашивает: «Кто это у нас?». Ваша личность никогда не лежит в cookie. Она подтягивается свежей каждый раз. Это даёт важное следствие: сервер может мгновенно передумать о вас. Удалил строку — и следующий же запрос с этим cookie приходит как от незнакомца. Забанить пользователя, принудительно разлогинить, отозвать скомпрометированную сессию — всё это один DELETE.
Тот же логин, та же проверка пароля. Но вместо записи в хранилище сервер собирает JSON-объект, подписывает его и отдаёт вам целиком. Токен состоит из трёх частей, разделённых точками: заголовок, полезная нагрузка, подпись. Спецификация — RFC 7519.
Самая важная вещь о полезной нагрузке, которую многие упускают в продакшене:
# возьмите среднюю часть любого JWT и просто прочитайте её
echo "$TOKEN" | cut -d. -f2 | base64 -d 2>/dev/null | jq
{
"sub": "user_8823",
"email": "maneshwar@example.com",
"role": "admin",
"exp": 1735689600
}
Никакого ключа, никакого пароля — просто base64. JWT подписан, а не зашифрован. Любой, у кого есть токен, может прочитать все утверждения внутри. jwt.io сделает это в браузере. Подпись не скрывает содержимое, она лишь доказывает, что содержимое не меняли после подписи. Поэтому никогда не кладите в payload то, что не напечатали бы на открытке: никаких секретов, внутренних флагов, «isTrialAbuser».
Забудьте про аббревиатуры. С сессиями правда живёт на вашем сервере, а клиент держит указатель на неё. С JWT правда живёт в кармане клиента, а сервер имеет способ проверить почерк. Всё остальное вытекает из этого.
Добавьте второй сервер — и увидите разницу. Сессиям нужно, чтобы каждый сервер имел доступ к одному хранилищу, а это сетевой хоп на каждый запрос и ещё одна точка отказа. JWT не требуют ничего общего: у каждого сервера есть ключ, каждый проверяет локально, добавить четвёртый сервер — не событие. Это действительно здорово, и именно поэтому JWT захватили микросервисы. Но посмотрите на нижнюю часть обеих колонок — там, где вы платите.
Вот вопрос, который решает всё: что происходит между моментом, когда вы решили, что пользователь должен быть разлогинен, и моментом, когда это реально происходит?
Для сессии этот разрыв — один запрос. Вы удаляете строку, следующий запрос падает, готово. Для простого JWT этот разрыв — сколько осталось на часах. Вы можете удалить пользователя из базы, отключить аккаунт, отозвать API-ключи, поджечь здание — токен всё равно работает. Каждый сервер, который его увидит, радостно проверит подпись, найдёт её валидной и обслужит запрос. Это не баг. Это дизайн. Statelessность означает, что никто ни с кем не сверяется, а информация «этот пользователь забанен» живёт где-то ещё.
Стандартное решение известно: сделать access-токен короткоживущим — около 15 минут — и добавить долгоживущий refresh-токен. Когда access истекает, клиент тихо обменивает refresh на новый. Пользователь ничего не замечает, окно кражи сокращается с дней до минут. Это работает, и вы должны так делать. Но посмотрите на refresh-эндпоинт:
app.post('/auth/refresh', async (req, res) => {
const { refreshToken } = req.body;
// вот оно
const stored = await redis.get(`refresh:${refreshToken}`);
if (!stored) return res.sendStatus(401); // отозван или не существовал
const { userId } = JSON.parse(stored);
const user = await db.users.findById(userId);
if (user.disabled) return res.sendStatus(401); // забанен с прошлого refresh
return res.json({ accessToken: signAccessToken(user) });
});
Посчитайте, что тут есть: поиск в хранилище, проверка отзыва, поход в базу, чтобы проверить, что пользователь ещё допущен. Это сессия. Вы написали сессию. Refresh-токен — это непрозрачный id, указывающий на серверное состояние, которое можно удалить в любой момент, — точное определение того, от чего мы якобы ушли. Разница лишь в том, что вы проверяете это раз в 15 минут вместо каждого запроса.
И это настоящий ответ: вы не выбираете между stateful и stateless. Вы выбираете, как часто готовы платить за состояние и как долго готовы терпеть ошибку между проверками. Сессии платят на каждом запросе и никогда не ошибаются. Простые JWT не платят никогда и могут ошибаться часами. Refresh-токены платят время от времени и ошибаются около пятнадцати минут.
Короткая версия: HMAC — симметричный, RSA и ECDSA — асимметричные. Но это скрывает суть. Настоящий вопрос: сколько сервисов могут выпускать токен?
С HMAC ключ, который проверяет токен, тот же, что и подписывает. Значит, каждый сервис, которому вы дали ключ, может подделать токен для любого пользователя с любой ролью, и все остальные примут его за настоящий. Внутри одного монолита — норм. Между командами или рядом с третьими лицами — это слишком много доверия ради проверки подписи.
С RSA или ECDSA auth-сервис держит приватный ключ, а все остальные получают публичный. Они могут проверять сколько угодно, но не могут создать ни одного токена. Утёкший публичный ключ ничего не стоит, потому что он публичный.
Ещё одна ловушка: заголовок токена сам говорит, какой алгоритм использовать, и исторически библиотеки просто верили ему. Атакующие ставили alg=none или переключали RS256 на HS256, чтобы публичный ключ использовался как HMAC-секрет. Auth0 описала классические баги. Современные библиотеки защищаются, но всё равно закрепите алгоритм сами:
jwt.verify(token, key, { algorithms: ["RS256"] }); // никогда не давайте токену выбирать
localStorage удобен, но доступен любому JavaScript на вашей странице. Одна плохая npm-зависимость или XSS-дыра — и токен ушёл. Никакой браузерный механизм это не остановит. HttpOnly cookie вообще нельзя прочитать из JavaScript, что убивает целый класс краж. Но браузеры автоматически отправляют cookie, чем пользуются CSRF-атаки, поэтому нужен SameSite=Lax или Strict и токен на изменяющих состояние запросах.
Заметили? Если вы положили JWT в HttpOnly cookie и проверяете серверный список отзыва — вы вернулись к сессиям, только с лишними шагами и большей кукой. Это не аргумент против JWT, это аргумент за то, чтобы понимать, какое свойство вам на самом деле нужно. Рекомендую почитать OWASP session management cheat sheet — это стоит двадцати минут.
Начните с ограничений, а не с аббревиатуры. Короткая версия:
Если выбрали JWT: короткие access-токены, отзываемые refresh-токены, асимметричные ключи, закреплённый алгоритм, HttpOnly cookie. Статья Sven Slootweg «Stop using JWT for sessions» — полезный противовес хайпу, даже если вы с чем-то не согласны.
Прямо сейчас посмотрите на свой код авторизации. Если вы используете JWT и уже добавили список отзыва или хранилище refresh-токенов — вы изобрели сессию. Подумайте, не проще ли использовать сессии напрямую. Если вам нужны свойства токена — используйте токен. Но не берите токен и не тратьте шесть месяцев на то, чтобы вернуть ему свойства сессии. Внимание команды ограничено, а поток ИИ-кода только усложняет поддержку безопасности.
Я строю LiveReview — AI-ревью кода с учётом радиуса взрыва для критически важных систем. Вместо того чтобы давать каждому диффу равное внимание, LiveReview оценивает каждый изменение по радиусу влияния через граф вызовов — так вы фокусируетесь на том, что реально важно. Попробуйте на своём коде.
Хочешь закрепить знания на практике?
Решай задачи на Algolit — интерактивная платформа для обучения
Начать бесплатно →