Сессии и токены: где хранить и как истекать
После входа всё держится на токене сессии — его кража равносильна краже пароля.
Сессия — состояние «пользователь залогинен», подтверждаемое идентификатором сессии или токеном при каждом запросе.
Почему хранение токена критично
Пароль вводят раз, а токен сессии путешествует с каждым запросом и даёт полный доступ от имени пользователя. Если он утечёт (через XSS, лог, незашифрованный канал), атакующий получает аккаунт без пароля. Поэтому два главных вопроса — где хранить и когда обнулять.
Полезно осознавать, что после успешного входа токен сессии становится эквивалентом пароля — а точнее, даже опаснее его. Пароль защищён 2FA, его не передают по сети после первого ввода, его хранят в виде хеша. Токен же, наоборот, в открытом виде ездит в каждом запросе, лежит в браузере и зачастую обходит второй фактор: тот, кто им завладел, уже «прошёл» аутентификацию. Поэтому любая утечка действующего токена — это полный захват сессии, и часто незаметный: легитимный пользователь продолжает работать как ни в чём не бывало, не подозревая, что параллельно с его сессией работает чужой.
Каналов утечки больше, чем кажется. Токен может попасть в URL (и осесть в истории браузера, логах прокси, заголовке Referer при переходе на сторонний сайт), в логи сервера, если туда пишут заголовки целиком, в систему аналитики или мониторинга ошибок, куда нечаянно отправили объект запроса. Отсюда два практических правила: токены аутентификации передают в заголовке или в куке, но не в адресной строке, и любые системы логирования настраивают так, чтобы они вырезали или маскировали значения токенов и кук.
Где хранить: httpOnly-cookie против localStorage
Распространённая ошибка — класть токен в localStorage: он доступен JavaScript, и любой XSS немедленно его украдёт. Безопаснее — httpOnly-cookie: браузер хранит и отправляет её сам, но скрипты её не видят.
| Хранилище | Доступно JS? | Кража через XSS |
| localStorage | да | тривиальна |
| httpOnly-cookie | нет | защищено от чтения скриптом |
# Безопасные флаги для куки с токеном
auth_cookie:
httpOnly: true # недоступна из JS -> XSS не украдёт
secure: true # только по HTTPS
sameSite: "Lax" # снижает CSRF
maxAge: 900 # короткая жизнь access-токена
Платой за куки является риск CSRF (их шлёт браузер сам) — поэтому в связке нужны SameSite и/или анти-CSRF-токены из предыдущего раздела. Это компромисс: cookie меняет «риск XSS-кражи» на «управляемый риск CSRF».
JWT: что это меняет
JWT — самодостаточный токен с подписью: сервер не хранит состояние, а проверяет подпись. Удобно, но есть нюанс: JWT по умолчанию не отзывается до истечения. Поэтому делают короткоживущий access-токен (минуты) и отдельный refresh-токен с большим сроком, который можно отозвать на сервере.
Корень компромисса — в самой природе самодостаточности. Классическая серверная сессия хранит идентификатор в базе: чтобы выкинуть пользователя, достаточно удалить строку — и при следующем запросе сессия не найдётся. JWT же по дизайну не требует обращения к хранилищу: сервер верит токену, если подпись верна и срок не истёк. Это даёт масштабируемость (любой узел проверит токен сам, без общей базы сессий), но ровно поэтому отозвать выданный JWT нечем — «удалять» нечего. Отсюда и стандартное решение: короткий срок жизни access-токена ограничивает окно, в течение которого украденный токен полезен, а отзыв реализуют на уровне refresh-токена, который как раз хранится и проверяется на сервере.
// Концептуальная схема
access = JWT, ttl ~ 15 мин // используется в каждом запросе
refresh = непредсказуемый id, ttl ~ дни, хранится на сервере (можно отозвать)
// access истёк -> по refresh выдаём новый access; refresh отозван -> выход
Истечение, ротация и отзыв
- Истечение. У любого токена ограниченный срок; «вечные» токены — находка для атакующего.
- Ротация при привилегированных действиях. После входа и смены пароля выдавайте новый идентификатор сессии (защита от session fixation).
- Отзыв. Logout и «выйти на всех устройствах» должны реально инвалидировать токены на сервере.
// Уязвимо (session fixation): тот же id сессии до и после входа
login(user) // id сессии не сменился
// Безопасно: при входе генерируем новый id, старый недействителен
session.regenerateId()
login(user)
Как работает под капотом: подпись JWT и алгоритм
JWT состоит из header, payload и подписи. Сервер пересчитывает подпись своим секретом и сверяет — так ловится подмена payload. Классическая ошибка — доверять полю alg из самого токена и принять alg: none или подмену алгоритма: алгоритм проверки задавайте на сервере жёстко, а не из токена. И payload JWT не зашифрован — это лишь base64; не кладите туда секреты.
Раскроем атаку на alg чуть подробнее, потому что она показательна. Поле alg в заголовке токена сообщает, каким алгоритмом подписан JWT — и наивная библиотека «послушно» проверяет подпись тем алгоритмом, который указал... сам токен. Подменив alg на none, атакующий заявляет «подписи нет», и если сервер это принимает, любой payload проходит как валидный. Ещё хитрее подмена асимметричного алгоритма на симметричный: публичный ключ, который сервер свободно раздаёт, начинает использоваться как секрет для проверки HMAC. Мораль одна: сервер должен сам решать, какой алгоритм он ожидает, и отвергать всё остальное, а не следовать подсказке из недоверенного токена.
Не менее частая беда — слабый или общий секрет подписи. Если ключ короткий и предсказуемый, его подбирают офлайн и начинают штамповать валидные токены для любого пользователя, включая администратора. Поэтому секрет должен быть длинным и случайным, храниться отдельно от кода (в секрет-менеджере или переменной окружения, а не в репозитории) и подлежать ротации. И помните про обратную сторону «не шифруется»: всё, что вы положили в payload, читаемо для любого, у кого есть токен, — идентификаторы и роли там уместны, а персональные данные, внутренние флаги и тем более секреты — нет.
Частые ошибки
- Токен в localStorage. XSS крадёт его мгновенно; предпочитайте httpOnly-cookie.
- Долгоживущий неотзываемый JWT. Logout «ничего не делает»; нужен короткий access + refresh.
- Не менять id сессии при входе. Открывает session fixation.
- Секреты в payload JWT. Он читаем; payload не шифруется по умолчанию.
Итоги
- Токен сессии — ключ от аккаунта; храните в httpOnly-cookie, а не в localStorage.
- JWT не отзывается сам: используйте короткий access + отзываемый refresh.
- Задавайте срок, ротируйте id при входе, обеспечьте реальный logout/отзыв.