Прямой ответ
HTTP не хранит состояние, поэтому аутентифицированного пользователя приходится «запоминать» между запросами. Стандартный механизм — сессия: сервер создаёт запись с уникальным идентификатором, а браузер хранит этот идентификатор в cookie и подставляет в каждый запрос.
Где хранить состояние:
- Серверная сессия. В базе или Redis лежат данные, в cookie — только случайный идентификатор. Преимущество: сессию можно отозвать мгновенно, данные не видны клиенту. Цена — общее хранилище для всех инстансов приложения.
- Клиентское хранение (подписанный cookie или JWT). Данные живут у клиента, сервер проверяет подпись. Преимущество — отсутствие общего хранилища. Цена — отзыв невозможен без дополнительного списка, а содержимое читаемо, если не зашифровано. Механика токенов — в статье про refresh-токены.
Обязательные флаги cookie:
| Флаг | Что делает |
|---|---|
HttpOnly |
Cookie недоступен из JavaScript — снижает ущерб от XSS |
Secure |
Передача только по HTTPS |
SameSite=Lax (или Strict) |
Ограничивает отправку cookie в межсайтовых запросах — защита от CSRF |
Path=/ |
Область действия; сужение без необходимости ломает часть маршрутов |
Domain |
Не задавать шире нужного: example.com отдаст cookie всем поддоменам |
Max-Age / Expires |
Срок жизни; без них cookie живёт до закрытия браузера |
response.set_cookie(
"session_id", session_id,
httponly=True, secure=True, samesite="lax",
path="/", max_age=8 * 3600,
)
Ключевые правила:
- Идентификатор — криптографически случайный. Не менее 128 бит энтропии; счётчики и хеши от имени пользователя недопустимы.
- Ротация при входе. После успешной аутентификации идентификатор меняется. Это закрывает фиксацию сессии, когда атакующий заранее подсовывает свой идентификатор.
- Ротация при повышении прав. Смена пароля, подтверждение платежа, включение 2FA — повод выпустить новый идентификатор и завершить остальные сессии.
- Отзыв должен работать. Выход из системы, смена пароля и блокировка пользователя обязаны немедленно инвалидировать сессии.
- Ограничение времени. Абсолютный срок жизни сессии (например, 8–24 часа) и неактивности (15–60 минут для чувствительных операций).
Практика: хранение и отзыв
Серверная сессия в Redis:
import secrets
def create_session(user_id: int, ttl: int = 8 * 3600) -> str:
sid = secrets.token_urlsafe(32)
redis.setex(f"sess:{sid}", ttl, json.dumps({"uid": user_id, "auth_at": now()}))
return sid
def revoke_all(user_id: int) -> None:
for key in redis.scan_iter(f"sess:*"):
data = json.loads(redis.get(key) or "{}")
if data.get("uid") == user_id:
redis.delete(key)
Для большого числа пользователей сканирование ключей непрактично — держите индекс «пользователь → список сессий» в отдельном наборе.
Таблица сессий в PostgreSQL удобна, когда нужен аудит: видно, откуда и когда создан сеанс, и его можно показать пользователю в списке активных устройств.
Рекомендации:
- Разделяйте сессию и «запомнить меня». Долгоживущий токен — отдельная сущность с отдельными правилами: одноразовый, привязанный к устройству, подлежащий ротации при каждом использовании.
- Не храните в cookie чувствительные данные. Ни паролей, ни полных платёжных реквизитов, ни персональных данных, если без этого можно обойтись.
- Проверяйте CSRF отдельно от SameSite.
SameSite=Laxпокрывает большинство сценариев, но для критичных операций нужен токен или двойная отправка — разбор в статье про CSRF и XSS. - Настройте CORS осознанно.
Access-Control-Allow-Credentials: trueвместе сOrigin: *— нерабочая и опасная комбинация; детали — в статье про CORS. - Логируйте создание и отзыв сессий. Это часть журнала безопасности — см. audit log.
Практический сценарий: угон сессии через поддомен
Контекст: после компрометации одного поддомена (staging.example.com) злоумышленник получил доступ к сессиям пользователей основного сайта.
- Причина. Cookie ставился с
Domain=.example.com, поэтому отправлялся на все поддомены, включая тестовый с устаревшим кодом и открытой регистрацией. - Дополнительно. Флаг
Secureне был выставлен, поэтому идентификатор сессии уходил по HTTP при первом переходе;HttpOnlyотсутствовал, и скрипт на поддомене мог прочитать cookie. - Исправление. (а) Убрали
Domain, оставив cookie в пределах основного хоста; (б) выставилиSecure,HttpOnly,SameSite=Lax; (в) включили HSTS, чтобы исключить первый незащищённый запрос; (г) отозвали все сессии и запросили повторный вход. - Проверка. Проверили выдачу cookie на всех маршрутах и убедились, что на поддомены он не уходит; добавили тест, который падает при появлении cookie без требуемых флагов.
- Профилактика. Настройки cookie вынесли в одну функцию-фабрику, чтобы нельзя было выставить их вручную в отдельном обработчике; ротация идентификатора при входе включена по умолчанию.
Вывод: большинство инцидентов с сессиями — не следствие слабых алгоритмов, а следствие неверных флагов cookie и слишком широкой области действия.
FAQ
Что выбрать: серверную сессию или JWT? Для классического веб-приложения серверная сессия проще и безопаснее: мгновенный отзыв, отсутствие данных у клиента. JWT оправдан, когда нет общего хранилища или клиент не браузер (мобильное приложение, межсервисные вызовы), и тогда обязателен механизм отзыва через короткоживущие access-токены и ротацию refresh-токенов.
Почему нельзя хранить токен в localStorage?
LocalStorage доступен из JavaScript, поэтому любая XSS-уязвимость приводит к краже токена. HttpOnly-cookie от скриптов закрыт. Обратная сторона — необходимость защиты от CSRF, которая решается SameSite и токеном.
Как правильно реализовать «выйти на всех устройствах»? Нужен серверный список сессий пользователя и его очистка. При клиентских токенах без состояния это невозможно без чёрного списка, поэтому для такой функции выбирают серверные сессии или короткое время жизни access-токена.
Что делать с сессиями при горизонтальном масштабировании? Хранить состояние в общем хранилище (Redis, БД), а не в памяти процесса. «Прилипающие» сессии на балансировщике работают, но ломают graceful shutdown и усложняют деплой — см. healthcheck и graceful shutdown.
Источники: OWASP Session Management Cheat Sheet, MDN Set-Cookie, OWASP CSRF Cheat Sheet — ссылки в шапке.