CORS
Почему браузер блокирует запросы к другому домену, что такое Same-Origin Policy и как CORS позволяет серверу явно открыть доступ нужным источникам.
Same-Origin Policy — откуда растут корни
Раньше сайты были устроены просто: один домен — одно приложение. Весь HTML, вся логика, все данные — на одном сервере. Потом появились SPA и REST API. Фронтенд отделился от бэкенда:
# Современное приложение — два разных домена Фронтенд: https://app.example.com ← статика: HTML, JS, CSS API: https://api.example.com ← данные: JSON
Пользователь открывает app.example.com. JavaScript на этой странице делает
запрос на api.example.com. Это наш собственный API — никакой угрозы нет.
Но браузер блокирует чтение ответа. Почему?
Браузер — не просто программа для отображения страниц. Он посредник между пользователем и интернетом. Он хранит куки, сессии, токены авторизации. При каждом запросе на сайт браузер автоматически прикрепляет куки этого сайта.
corp.ru. В соседней вкладке открыл
посторонний сайт. JavaScript на нём делает запрос на corp.ru/api/documents
— браузер автоматически прикрепляет твою куку авторизации. Сервер видит валидную
куку и отдаёт данные. Чужой сайт прочитал твои корпоративные документы — а ты
даже не заметил.
Именно чтобы это предотвратить, браузеры ввели Same-Origin Policy (политика одного источника):
Это осознанное архитектурное решение. Браузер — единственный, кто одновременно знает и с какого сайта пришёл JavaScript, и какие куки у пользователя хранятся. Ни сервер, ни сеть этого не знают. Поэтому именно браузер несёт ответственность за это ограничение.
Что такое origin
Origin — это три вещи вместе: протокол, домен и порт.
Два адреса считаются одним origin'ом только если все три части совпадают:
| URL | Отличие | Результат |
|---|---|---|
| https://example.com/about | только путь отличается | одинаковый ✓ |
| http://example.com | другой протокол | разный ✗ |
| https://api.example.com | другой домен (поддомен) | разный ✗ |
| https://example.com:8443 | другой порт | разный ✗ |
app.example.com и api.example.com — разные origin'ы,
даже если физически это один сервер и одна компания.
Что именно блокируется
Важный нюанс, который часто путают. SOP не блокирует сам запрос. Запрос уходит на сервер, сервер его обрабатывает и отвечает. Блокируется доступ JavaScript к ответу.
CORS error, и данные из ответа JavaScript не получит.
CORS — управляемое исключение из SOP
SOP отлично защищает от чужих сайтов. Но он не различает «чужой враждебный сайт» и «наш собственный фронтенд на другом домене».
CORS (Cross-Origin Resource Sharing) — механизм, который позволяет серверу явно сказать браузеру: «запросы с вот этих доменов — разрешаю». По умолчанию всё заблокировано. Сервер явно открывает доступ тем, кому нужно.
Работает это через HTTP-заголовок в ответе сервера:
Access-Control-Allow-Origin: https://app.example.com
Браузер видит этот заголовок, сравнивает с origin'ом страницы — и если совпадает, отдаёт ответ JavaScript-коду.
Origin: https://app.example.com
← 200 OK
Access-Control-Allow-Origin:
https://app.example.com
Content-Type: application/json
✓ браузер отдаёт ответ JS
Origin: https://app.example.com
← 200 OK
Content-Type: application/json
[данные пришли, но...]
✗ браузер блокирует чтение
Простые запросы и preflight
Браузер ведёт себя по-разному в зависимости от того, насколько «безопасен» запрос. Одни запросы он отправляет напрямую, перед другими сначала спрашивает разрешения.
GET, HEAD или POSTContent-Type: text/plain, multipart/form-data или application/x-www-form-urlencodedPUT, DELETE, PATCHAuthorizationContent-Type: application/json ← самый частый случайТакие запросы существовали задолго до CORS — обычные HTML-формы работали именно так. Поэтому для них preflight не нужен. Но почти любой REST API работает с JSON и авторизацией — значит preflight будет везде.
Простой запрос — один туда-обратно
→ Запрос GET /products HTTP/1.1 Origin: https://app.example.com ← Ответ 200 OK Access-Control-Allow-Origin: https://app.example.com [данные]
Один запрос, один ответ. Браузер проверяет Allow-Origin в ответе
и либо отдаёт данные JavaScript, либо нет.
Preflight — запрос перед запросом
Если запрос «непростой» — браузер сначала отправляет preflight.
Это предварительный запрос методом OPTIONS к тому же адресу:
«сервер, я собираюсь сделать вот такой запрос — ты разрешишь?»
OPTIONS /api/users/42 Origin: https://app.example.com Access-Control-Request-Method: DELETE Access-Control-Request-Headers: Authorization
204 No Content Access-Control-Allow-Origin: https://app.example.com Access-Control-Allow-Methods: GET, POST, DELETE Access-Control-Allow-Headers: Authorization, Content-Type Access-Control-Max-Age: 3600
DELETE /api/users/42 Origin: https://app.example.com Authorization: Bearer eyJhb... ← 204 No Content Access-Control-Allow-Origin: https://app.example.com
Зачем это нужно: DELETE — разрушительная операция. Браузер убеждается,
что сервер осознанно разрешает такие запросы с этого домена, прежде чем их отправлять.
Кеширование preflight
Каждый раз отправлять два запроса вместо одного накладно. Поэтому сервер указывает сколько секунд браузер может помнить результат preflight:
Access-Control-Max-Age: 3600 ← 1 час
В течение этого времени браузер не будет переспрашивать — просто отправит основной запрос напрямую.
Без Max-Age preflight летит перед каждым непростым запросом.
Все CORS-заголовки
Заголовки запроса — браузер ставит автоматически
Заголовки ответа — сервер ставит
Access-Control-Allow-Origin: https://app.example.com — конкретный домен.Access-Control-Allow-Origin: * — любой домен. Подходит только для полностью публичных API. Несовместим с credentials.
Access-Control-Allow-Methods: GET, POST, PUT, DELETE, PATCHAccess-Control-Allow-Headers: Authorization, Content-Type, X-Request-IDAccess-Control-Max-Age: 3600Authorization в кросс-доменных запросах.
По умолчанию браузер их не отправляет, даже если они есть.Нужны обе стороны: сервер ставит
Allow-Credentials: true,
и клиент явно указывает credentials: 'include' в fetch.
Несовместим со звёздочкой в Allow-Origin — только конкретный домен.
Content-Type, Cache-Control
и ещё несколько). Кастомные заголовки нужно явно разрешить.Access-Control-Expose-Headers: X-Request-ID, X-Total-Count
Несколько доменов
Access-Control-Allow-Origin принимает только одно значение — нельзя
перечислить несколько доменов через запятую. Стандартное решение: сервер сам проверяет
заголовок Origin запроса и, если он в белом списке, отражает его в ответе.
# Несколько доменов через map map $http_origin $cors_origin { https://app.example.com $http_origin; https://admin.example.com $http_origin; default ""; } server { add_header Access-Control-Allow-Origin $cors_origin always; add_header Vary Origin always; # важно: кеш должен учитывать Origin }
Allow-Origin
и будут отдавать его всем, даже тем кому доступ закрыт.
Итог
Same-Origin Policy — браузер по умолчанию не даёт JavaScript читать ответы от других доменов. Это защита от чтения чужих данных через автоматически прикрепляемые куки авторизации.
CORS — механизм явного разрешения. Сервер говорит: «вот этим доменам — можно». Работает через HTTP-заголовки в ответе. Браузер проверяет их и принимает решение.
Два режима
| Режим | Когда | Как работает |
|---|---|---|
| Простой запрос | GET/HEAD/POST без Authorization и без JSON | Один запрос — проверка Allow-Origin в ответе |
| Preflight | DELETE/PUT/PATCH, Authorization, Content-Type: application/json | Сначала OPTIONS, потом основной запрос |
На практике
| Заголовок | Роль |
|---|---|
| Access-Control-Allow-Origin | Главный — кому разрешён доступ |
| Access-Control-Allow-Methods | Разрешённые методы (для preflight) |
| Access-Control-Allow-Headers | Разрешённые заголовки (для preflight) |
| Access-Control-Max-Age | Кеш preflight — иначе два запроса вместо одного |
| Access-Control-Allow-Credentials | Разрешить куки и Authorization в кросс-доменных запросах |
| Access-Control-Expose-Headers | Какие заголовки ответа видит JavaScript |
| Vary: Origin | Обязателен при нескольких разрешённых доменах |
Access-Control-Allow-Origin ставится
на одном уровне — или nginx, или бэкенд. Если оба добавят этот заголовок,
браузер получит дубликат и выдаст ошибку. Используйте параметр always
в nginx — иначе заголовок не добавится к ответам 4xx/5xx.