Сессии и токены: где хранить и как истекать

После входа всё держится на токене сессии — его кража равносильна краже пароля.

Сессия — состояние «пользователь залогинен», подтверждаемое идентификатором сессии или токеном при каждом запросе.

Почему хранение токена критично

Пароль вводят раз, а токен сессии путешествует с каждым запросом и даёт полный доступ от имени пользователя. Если он утечёт (через 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/отзыв.
Проверьте себя
1. Почему хранить токен в localStorage менее безопасно, чем в httpOnly-cookie?
AlocalStorage медленнее
BlocalStorage доступен JavaScript, поэтому любой XSS крадёт токен; httpOnly-куку скрипты прочитать не могут
ChttpOnly-куки не отправляются на сервер
DlocalStorage не работает на мобильных
2. Зачем при использовании JWT делают короткий access-токен и отдельный refresh-токен?
AЧтобы экономить трафик
BJWT сам по себе не отзывается до истечения; короткий access ограничивает окно кражи, а отзываемый refresh даёт реальный logout
CЧтобы шифровать payload
DЧтобы хранить два пароля
3. Что предотвращает регенерация идентификатора сессии при входе?
ASQL-инъекцию
BSession fixation — навязывание жертве заранее известного атакующему id сессии
CПереполнение буфера
DCSRF полностью