Сообщение blocked by CORS policy означает, что браузер не разрешил JavaScript прочитать ответ другого origin. Это не обязательно значит, что запрос не дошёл до сервера: простой запрос браузер отправляет сразу, а ответ скрывает от кода, если проверка CORS не пройдена. Запрос с preflight останавливается раньше фактического запроса, когда сервер не разрешил заявленные метод или заголовки.
Исправление обычно находится на сервере или промежуточном прокси. Сервер должен проверить Origin по списку разрешённых источников и вернуть согласованный набор CORS-заголовков. Клиентский try…catch может обработать отказ, но не может выдать странице доступ к заблокированному ответу.
Что браузер считает другим origin
Origin состоит из схемы, хоста и порта. Все три части должны совпасть:
| Страница | API | Результат |
|---|---|---|
https://app.example/profile | https://app.example/api/user | Один origin: путь не участвует в сравнении |
https://app.example | https://api.example | Разные хосты |
https://app.example | http://app.example | Разные схемы |
http://localhost:5173 | http://localhost:3000 | Разные порты |
Политика одного источника ограничивает чтение данных между origin в браузере. CORS добавляет контролируемое исключение: сервер сообщает, коду с какого origin разрешено читать ответ. Поэтому соседние поддомены и два локальных сервера разработки тоже требуют CORS, хотя принадлежат одному проекту.
Браузер сам добавляет заголовок запроса Origin, например:
Origin: https://app.example
JavaScript не должен выставлять его вручную. Сервер сравнивает значение с точным списком разрешённых origin и для совпавшего источника возвращает:
Access-Control-Allow-Origin: https://app.example
Vary: Origin
Этот фрагмент корректен только если https://app.example уже прошёл серверную проверку. Нельзя безусловно копировать в ответ любое присланное значение Origin: тогда список разрешений фактически исчезнет. Access-Control-Allow-Origin принимает один origin, а не список через запятую.
Vary: Origin нужен, когда ответный Access-Control-Allow-Origin зависит от запроса. Он сообщает кешу, что ответы для разных значений Origin нельзя считать одной и той же версией. Если прокси уже формирует Vary по другим полям, Origin добавляют к существующему списку, а не затирают его.
Простой запрос и запрос с preflight
Термин «простой запрос» обозначает узкий набор CORS-запросов, которые браузер отправляет без предварительного OPTIONS. Метод должен быть GET, HEAD или POST; разрешены только заголовки из списка CORS-safelisted, а для явно заданного Content-Type — только application/x-www-form-urlencoded, multipart/form-data или text/plain с ограничениями Fetch Standard.
Например, GET без нестандартных заголовков обычно уходит сразу. Сервер всё равно должен вернуть подходящий Access-Control-Allow-Origin, иначе браузер получит ответ по сети, но не передаст его JavaScript. Поэтому CORS нельзя считать защитой от изменяющих запросов: простой POST способен выполнить действие на сервере до того, как браузер скроет ответ.
Preflight требуется, когда запрос выходит за эти рамки. Типичные причины — PUT, PATCH или DELETE, заголовок Authorization, собственный заголовок наподобие X-Request-ID либо Content-Type: application/json. Сначала браузер отправляет OPTIONS с описанием будущего запроса:
OPTIONS /orders HTTP/1.1
Origin: https://app.example
Access-Control-Request-Method: POST
Access-Control-Request-Headers: authorization, content-type
По Fetch Standard preflight не передаёт учётные данные. Клиентские TLS-сертификаты — известное браузерное отклонение от этого правила, поэтому на них нельзя строить переносимую логику. Сервер должен обработать OPTIONS до проверки пользовательской сессии и решить, разрешены ли сочетание origin, путь, метод и заголовки. Успешный минимальный ответ может выглядеть так:
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.example
Access-Control-Allow-Methods: POST
Access-Control-Allow-Headers: Authorization, Content-Type
Access-Control-Allow-Credentials: true
Vary: Origin
Этот пример подходит только для маршрута, который разрешает запросы с учётными данными от https://app.example. Перечисляйте лишь реально поддерживаемые методы и заголовки. После успешного preflight браузер отправит фактический POST; его ответ тоже обязан содержать Access-Control-Allow-Origin, а при учётных данных — Access-Control-Allow-Credentials: true.
Preflight проверяет разрешение до основного POST
Что означают ответные CORS-заголовки
Access-Control-Allow-Origin разрешает браузерному коду с одного origin читать ответ. Значение * допустимо только для публичного ответа без учётных данных. Для нескольких доверенных origin сервер выбирает совпавший origin из списка и возвращает его вместе с Vary: Origin.
Access-Control-Allow-Methods отвечает на Access-Control-Request-Method в preflight. Он не заменяет маршрутизацию, аутентификацию или проверку прав: фактический запрос сервер всё равно обрабатывает по обычным правилам.
Access-Control-Allow-Headers отвечает на Access-Control-Request-Headers и разрешает заголовки фактического запроса. Он не перечисляет заголовки ответа. Сравнение имён регистронезависимо, но в конфигурации полезно сохранять единое написание.
Access-Control-Allow-Credentials: true разрешает показать ответ коду, когда запрос использует учётные данные: cookie, HTTP-аутентификацию или клиентский TLS-сертификат. Для cross-origin fetch() отправку cookie обычно запрашивают через credentials: 'include'. Одного серверного заголовка недостаточно, а политики SameSite и сторонних cookie продолжают действовать.
Сочетание Access-Control-Allow-Origin: * и Access-Control-Allow-Credentials: true браузер отвергает, когда запрос использует режим credentials: 'include'. Для cross-origin cookie возвращайте точный разрешённый origin и Access-Control-Allow-Credentials: true. Заголовок Authorization сам по себе вызывает preflight, но не переключает режим credentials на include. Если маршрут действительно публичный и не использует учётные данные, оставьте * и не добавляйте Access-Control-Allow-Credentials.
Как найти сломанный этап в DevTools
Откройте Console и Network, очистите журнал и повторите действие. Текст в Console обычно называет конкретную проверку: отсутствующий Access-Control-Allow-Origin, несовпавший origin, неразрешённый метод или заголовок, недопустимый wildcard с credentials либо сбой preflight.
Дальше разберите обмен по фактам:
- Сверьте origin страницы со значением
Originв запросе. Учитывайте схему, полный хост и порт;localhostи127.0.0.1— разные хосты. - Если в Network есть
OPTIONS, откройте его отдельно. ПроверьтеAccess-Control-Request-Method,Access-Control-Request-Headers, статус и все ответныеAccess-Control-Allow-*. - Если
OPTIONSуспешен, найдите фактический запрос. CORS-заголовки должны присутствовать и на его ответе, включая ответы4xxи5xx, которые JavaScript должен уметь прочитать. - Если фактического запроса нет, исправляйте preflight. Если
OPTIONSнет, запрос либо простой, либо разрешение уже взято из preflight-кеша, либо браузер остановил запрос раньше из-за другой политики или сетевого сбоя. - Сравните наблюдение с серверными логами. Так видно, получил ли сервер только
OPTIONS, получил ли фактический запрос и какой компонент сформировал ответ.
Общий TypeError: Failed to fetch сам по себе не доказывает CORS: Fetch так же сообщает о части сетевых и защитных отказов. Если Console не называет CORS, используйте отдельную диагностику Failed to fetch.
Где ломается настройка сервера и прокси
Чаще всего приложение правильно обрабатывает успешный API-ответ, но CORS-заголовки теряются на другой ветке. Проверьте весь путь запроса:
- обработчик CORS выполняется до аутентификации и умеет ответить на
OPTIONSбез пользовательской cookie; - роутер разрешает
OPTIONSдля нужного пути, а прокси не отвечает на него собственным404,405или401; - балансировщик, CDN и обратный прокси не удаляют и не дублируют
Access-Control-Allow-Origin; - редирект не переносит preflight или фактический запрос на другой origin с другой политикой;
- CORS-заголовки добавляются к ошибочным ответам API, а не только к
2xx; - кеш учитывает
Vary: Origin, если сервер отражает один из нескольких разрешённых origin.
Ответ сервера удобно проверить без браузера, явно воспроизведя preflight:
curl -i -X OPTIONS 'https://api.example/orders' \
-H 'Origin: https://app.example' \
-H 'Access-Control-Request-Method: POST' \
-H 'Access-Control-Request-Headers: authorization, content-type'
Эта команда проверяет статус и заголовки, но не исполняет браузерный алгоритм CORS. Успешный curl, Postman или серверный HTTP-клиент не опровергает ошибку в браузере: такие клиенты могут отправить запрос и прочитать ответ независимо от Access-Control-Allow-*.
CORS не заменяет контроль доступа
CORS исполняет браузер и решает, можно ли показать ответ JavaScript другого origin. Он не запрещает обращаться к API серверным программам и не подтверждает личность пользователя. Поэтому API отдельно проверяет аутентификацию, права на ресурс, допустимость входных данных и лимиты запросов.
Нельзя полагаться на CORS и для защиты изменяющих операций от CSRF. Часть cross-origin-запросов уходит без preflight, а сокрытие ответа не отменяет серверный эффект. Для cookie-аутентификации нужны подходящие атрибуты cookie и защита от CSRF согласно модели приложения.
Не маскируйте ошибку клиентскими обходами. mode: 'no-cors' оставляет JavaScript непрозрачный ответ без доступных статуса, заголовков и тела. Отключение браузерной защиты работает только в небезопасном локальном окружении. Публичный CORS-прокси передаёт ему запросы и секреты и добавляет чужую точку отказа. Правильное место исправления — конфигурация API или контролируемого обратного прокси с точным списком разрешённых origin.