CORS — это одна из тех тем, где разработчики чаще всего теряют несколько часов жизни, глядя в консоль браузера и видя красную строчку с текстом "has been blocked by CORS policy". Я через это прошёл десятки раз, и сейчас расскажу всё, что знаю о настройке CORS-заголовков правильно — на Nginx, Apache, PHP и в облачных конфигурациях.
Что такое CORS и почему это вообще важно
CORS расшифровывается как Cross-Origin Resource Sharing — механизм, который позволяет или запрещает браузеру делать запросы к ресурсам с другого домена, протокола или порта. Придумали это не для того, чтобы усложнить жизнь разработчикам, а чтобы защитить пользователей от атак типа CSRF, когда вредоносный сайт пытается от имени пользователя стянуть данные с другого ресурса.
Грубо говоря, браузер работает по принципу Same-Origin Policy: скрипт с домена example.com не может просто так сделать fetch-запрос к api.otherdomain.com. Сервер должен явно разрешить это через специальные HTTP-заголовки. Если заголовков нет — браузер блокирует ответ, даже если сервер вернул 200 OK. Именно поэтому столько путаницы: сервер отвечает нормально, но браузер всё равно выдаёт ошибку.
Я помню случай с одним клиентом — интернет-магазин на Bitrix, который хотел подключить стороннее мобильное приложение на React Native к своему API. Мы потратили полдня, пока не поняли, что проблема не в коде приложения и не в логике API — просто на сервере не было ни одного CORS-заголовка. Сервер крутился на Nginx, и стоило добавить буквально 5 строк конфига — всё заработало. Но эти 5 строк надо знать правильно, иначе либо сломаешь безопасность, либо ничего не заработает.
Если ты работаешь с API-интеграциями — обязательно прочитай пошаговое руководство по настройке API интеграций, там много пересекающихся тем.
Как работает CORS: предварительные запросы и preflight
Прежде чем лезть в конфиги, нужно понять механизм. CORS-запросы делятся на два типа: простые (simple requests) и сложные, которые требуют предварительного запроса — preflight.
Простые запросы — это GET, POST с Content-Type: application/x-www-form-urlencoded, multipart/form-data или text/plain. Браузер отправляет их сразу, без предварительной проверки, и просто смотрит на заголовки ответа. Если сервер вернул нужный Access-Control-Allow-Origin — всё хорошо.
Сложные запросы — это всё остальное: PUT, DELETE, PATCH, POST с JSON-телом, запросы с кастомными заголовками (например, Authorization). Перед таким запросом браузер автоматически отправляет OPTIONS-запрос на тот же URL — это и есть preflight. Сервер должен ответить на этот OPTIONS-запрос с нужными CORS-заголовками и статусом 200 или 204. Только после этого браузер отправит реальный запрос.
На практике это значит, что если у тебя REST API с JSON и токеном авторизации — у тебя сложные запросы, и ты обязан обработать OPTIONS. Многие забывают про это, настраивают заголовки только для GET/POST, а потом удивляются, почему preflight возвращает 405 Method Not Allowed.
Настройка CORS на Nginx
Nginx — мой основной инструмент для настройки CORS на продакшн-серверах. Конфигурация получается чистой, не зависит от кода приложения и применяется сразу ко всем запросам. Вот базовый блок, который я использую для большинства проектов:
server {
listen 443 ssl;
server_name api.example.com;
# Список разрешённых origins
set $cors_origin "";
if ($http_origin ~* "^https://(www\.)?example\.com$") {
set $cors_origin $http_origin;
}
if ($http_origin ~* "^https://app\.example\.com$") {
set $cors_origin $http_origin;
}
location / {
# Устанавливаем заголовки для всех запросов
add_header 'Access-Control-Allow-Origin' $cors_origin always;
add_header 'Access-Control-Allow-Credentials' 'true' always;
add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, PATCH, OPTIONS' always;
add_header 'Access-Control-Allow-Headers' 'Authorization, Content-Type, Accept, X-Requested-With, Cache-Control' always;
add_header 'Access-Control-Max-Age' '86400' always;
# Обработка preflight-запросов
if ($request_method = 'OPTIONS') {
add_header 'Access-Control-Allow-Origin' $cors_origin always;
add_header 'Access-Control-Allow-Credentials' 'true' always;
add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, PATCH, OPTIONS' always;
add_header 'Access-Control-Allow-Headers' 'Authorization, Content-Type, Accept, X-Requested-With, Cache-Control' always;
add_header 'Access-Control-Max-Age' '86400' always;
add_header 'Content-Length' '0';
add_header 'Content-Type' 'text/plain; charset=utf-8';
return 204;
}
proxy_pass http://127.0.0.1:9000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}
Несколько важных моментов в этой конфигурации. Во-первых, я использую динамический $cors_origin вместо жёсткого *. Это принципиально, если ты используешь Access-Control-Allow-Credentials: true — с wildcard * и credentials браузер откажет. Во-вторых, директива always в add_header означает, что заголовок будет добавлен даже при ошибочных статусах (4xx, 5xx) — без неё браузер не увидит CORS-заголовков при ошибках и покажет неправильное сообщение.
В-третьих, Access-Control-Max-Age: 86400 — это кеш preflight-ответа на 24 часа. Без него браузер будет слать OPTIONS перед каждым запросом. На активно используемом API это лишняя нагрузка.
Одна тонкость с Nginx: директивы add_header внутри блока if перекрывают родительские add_header. Поэтому в примере выше заголовки продублированы и в основном блоке, и внутри if ($request_method = 'OPTIONS'). Да, это некрасиво, но это особенность Nginx. Альтернатива — использовать map и include, но для большинства проектов дублирование проще.
Настройка CORS на Apache и .htaccess
Если у тебя Apache или хостинг, где нет доступа к конфигу Nginx — настраиваем через .htaccess. Это чуть менее гибко, но вполне рабочий вариант. На WordPress-сайтах я обычно делаю так:
# Включаем модуль headers
Header always set Vary "Origin"
# Разрешаем конкретные origins
SetEnvIf Origin "^https://(www\.)?example\.com$" CORS_ALLOWED_ORIGIN=$0
SetEnvIf Origin "^https://app\.example\.com$" CORS_ALLOWED_ORIGIN=$0
Header always set Access-Control-Allow-Origin "%{CORS_ALLOWED_ORIGIN}e" env=CORS_ALLOWED_ORIGIN
Header always set Access-Control-Allow-Credentials "true" env=CORS_ALLOWED_ORIGIN
Header always set Access-Control-Allow-Methods "GET, POST, PUT, DELETE, PATCH, OPTIONS" env=CORS_ALLOWED_ORIGIN
Header always set Access-Control-Allow-Headers "Authorization, Content-Type, Accept, X-Requested-With" env=CORS_ALLOWED_ORIGIN
Header always set Access-Control-Max-Age "86400" env=CORS_ALLOWED_ORIGIN
# Обработка preflight
RewriteEngine On
RewriteCond %{REQUEST_METHOD} OPTIONS
RewriteRule ^(.*)$ $1 [R=204,L]
Важно: для работы этого конфига нужны включённые модули mod_headers и mod_rewrite. На большинстве хостингов они есть по умолчанию. Если нет — обратись к хостеру или используй PHP-решение ниже.
Заголовок Vary: Origin здесь не случаен. Он говорит кешам (CDN, прокси) что ответ может различаться в зависимости от Origin. Без него CDN может закешировать ответ для одного origin и отдать его другому — тогда CORS перестанет работать. Если у тебя настроен CDN, рекомендую посмотреть руководство по настройке CDN — там эта тема тоже затрагивается.
CORS на уровне PHP: Laravel, WordPress, Bitrix
Иногда настраивать CORS на уровне веб-сервера неудобно или невозможно. Тогда делаем это в коде. Вот как я решаю это в разных стеках.
Laravel
В Laravel начиная с версии 7+ есть встроенный пакет fruitcake/laravel-cors, который сейчас называется laravel/sanctum для API или просто middleware. Настройка через config/cors.php:
['api/*', 'sanctum/csrf-cookie'],
'allowed_methods' => ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'],
'allowed_origins' => [
'https://example.com',
'https://app.example.com',
],
'allowed_origins_patterns' => [
// Можно использовать regex: '/^https:\/\/.*\.example\.com$/'
],
'allowed_headers' => [
'Content-Type',
'Authorization',
'X-Requested-With',
'Accept',
'Origin',
],
'exposed_headers' => ['X-Total-Count', 'X-Page'],
'max_age' => 86400,
'supports_credentials' => true,
];
Убедись, что middleware \Fruitcake\Cors\HandleCors::class (или \Illuminate\Http\Middleware\HandleCors::class в Laravel 10+) стоит первым в глобальном middleware-стеке в app/Http/Kernel.php. Если поставить его после других middleware — могут быть проблемы с preflight.
WordPress
В WordPress CORS удобнее всего настраивать через functions.php или отдельный плагин. Я обычно добавляю это в functions.php темы или в MU-плагин:
Для WordPress-проектов, где нужна серьёзная безопасность, рекомендую также посмотреть статью про Content Security Policy — CORS и CSP хорошо работают в паре.
Bitrix
В Bitrix я обычно делаю CORS через init.php или отдельный обработчик в /local/php_interface/init.php:
Типичные CORS-ошибки и как их дебажить
За годы работы я собрал коллекцию самых частых CORS-ошибок. Вот они с объяснениями и решениями.
"The 'Access-Control-Allow-Origin' header contains multiple values" — самая распространённая. Заголовок дублируется: один раз в Nginx, второй в PHP. Убери заголовки из одного места. Правило простое: если CORS настроен на уровне веб-сервера — убирай из PHP. Если в PHP — убирай из веб-сервера.
"Credentialed requests are not supported if the CORS header 'Access-Control-Allow-Origin' value is '*'" — пытаешься использовать wildcard вместе с credentials. Нельзя. Нужно указать конкретный origin. Никаких звёздочек, когда есть куки или Authorization-заголовки.
Preflight возвращает 404 или 405 — сервер не обрабатывает OPTIONS. Проверь, что OPTIONS разрешён. В Nginx убедись, что блок if ($request_method = 'OPTIONS') стоит до proxy_pass. В Laravel — что CORS middleware стоит до роутинга.
CORS работает в браузере, но не на мобильном — честно говоря, звучит странно, но я такое видел. Оказалось, что на мобильном использовался другой поддомен через редирект, и его не было в списке разрешённых origins. Всегда логируй реальный Origin из запроса.
Для дебага я использую простой подход: добавляю в Nginx лог:
log_format cors_debug '$remote_addr - $http_origin - $request - $status - $sent_http_access_control_allow_origin';
access_log /var/log/nginx/cors_debug.log cors_debug;
Это сразу показывает, какой Origin пришёл, какой статус вернули и какой заголовок отправили. Очень помогает. Про настройку логов подробнее написал в статье про мониторинг и анализ ошибок через логи.
Безопасность CORS: чего нельзя делать
Тут я хочу остановиться отдельно, потому что видел проекты, где CORS настроен так, что лучше бы его вообще не было.
Первая и главная ошибка — Access-Control-Allow-Origin: * на API, которое работает с авторизацией и личными данными. Wildcard означает "разрешено с любого сайта в интернете". Если при этом ты ещё и доверяешь куки или токену из заголовка — это дыра. Злоумышленник делает сайт, пользователь его открывает, и скрипт на этом сайте тихо делает запросы к твоему API от имени пользователя. Wildcard подходит только для публичных, полностью открытых ресурсов без авторизации — например, публичные CDN-файлы, шрифты, открытые данные.
Вторая ошибка — валидация Origin через contains вместо exact match. Я видел код вида if (strpos($origin, 'example.com') !== false). Это пропустит https://evil-example.com и https://notexample.com. Всегда используй строгое сравнение или regex с якорями начала и конца строки.
Третья ошибка — разрешать Access-Control-Allow-Headers: * вместе с credentials. Это не работает в большинстве браузеров, и ты получишь ошибку. Перечисляй заголовки явно.
Четвёртая — не проверять Origin вообще, просто отражать его обратно без whitelist. Код вида header('Access-Control-Allow-Origin: ' . $_SERVER['HTTP_ORIGIN']) без проверки — это то же самое, что wildcard, только хуже, потому что ещё и credentials работают. Не делайте так никогда.
CORS и CDN: особенности работы
Если у тебя подключён CDN — Cloudflare, AWS CloudFront, Fastly или что-то ещё — CORS становится интереснее. CDN кешируют ответы, и если они закешировали ответ без CORS-заголовков (например, первый запрос пришёл без Origin), следующие запросы с другого сайта получат закешированный ответ без заголовков — и CORS сломается.
Решение — заголовок Vary: Origin. Он говорит CDN: "Кешируй ответы отдельно для каждого Origin". Без него CDN не понимает, что ответ зависит от Origin, и отдаёт один закешированный вариант всем.
Cloudflare дополнительно имеет собственные CORS-настройки в Transform Rules. Там можно настроить добавление заголовков прямо на уровне CDN, не трогая сервер. Это удобно, когда сервер — legacy-система, в которую лезть страшно. Но и здесь нужна осторожность: если Cloudflare добавит заголовок, а сервер тоже добавит — получишь дублирование.
На одном проекте с Laravel 9 и PHP 8.1 у нас был именно такой кейс: Cloudflare настроен криво, сервер настроен правильно, в итоге два Access-Control-Allow-Origin в ответе. Браузер такое не любит и выдаёт ошибку. Потратили час на диагностику, пока не запустили curl -I -H "Origin: https://app.example.com" https://api.example.com/endpoint и не увидели два заголовка.
CORS для WebSockets и Server-Sent Events
Отдельная тема — CORS для WebSocket-соединений и SSE (Server-Sent Events). Тут есть нюанс: WebSocket при установке соединения (handshake) использует HTTP Upgrade-запрос, который технически является cross-origin, но браузер не проверяет CORS-заголовки для WebSocket так же, как для обычных HTTP-запросов. Вместо этого сервер WebSocket должен сам проверять заголовок Origin и принимать или отклонять соединение.
Для SSE всё проще — это обычный HTTP GET-запрос, и CORS работает стандартным образом. Добавляй заголовки как для обычного API-эндпоинта.
Если используешь Node.js с Socket.io — там есть встроенная опция cors в конфиге:
const io = require('socket.io')(server, {
cors: {
origin: ['https://example.com', 'https://app.example.com'],
methods: ['GET', 'POST'],
credentials: true
}
});
Для PHP-реализаций WebSocket (Ratchet, Swoole) логика та же — проверяй Origin в onOpen и закрывай соединение, если origin не в whitelist.
Чек-лист настройки CORS
Подготовил для себя (и теперь делюсь с вами) список вещей, которые нужно проверить после настройки CORS:
- Простой GET-запрос с разрешённого origin — должен вернуть заголовок
Access-Control-Allow-Originс точным значением origin - Простой GET-запрос с неразрешённого origin — заголовок должен отсутствовать или быть пустым
- OPTIONS preflight для POST с JSON — должен вернуть 204 с нужными заголовками
- POST с JSON и Authorization — должен пройти после preflight
- Запрос с wildcard origin при включённых credentials — должен быть заблокирован
- Дублирование заголовков — проверь
curl -v, что каждый заголовок присутствует ровно один раз - Заголовок
Vary: Origin— должен присутствовать, особенно если есть CDN - Ошибочные ответы (4xx, 5xx) — CORS-заголовки должны присутствовать и в них (директива
alwaysв Nginx)
Проверить всё это можно через curl или через расширение для браузера CORS Unblock (только для тестирования!). Я обычно пишу маленький bash-скрипт для smoke-test после деплоя — это экономит время при следующем деплое.
Если у тебя сложный проект и нужна помощь с настройкой сервера — можно обратиться за доработкой сайта, разберём конкретную конфигурацию. Или если нужна постоянная поддержка — есть поддержка Bitrix-проектов и поддержка WordPress.
Итого: как не наступить на грабли
CORS — это не сложно, если понимать механизм. Три главных правила, которые я усвоил за годы практики: никогда не используй wildcard с credentials, всегда обрабатывай OPTIONS-запросы, и добавляй Vary: Origin если у тебя CDN.
Настраивай CORS на одном уровне — либо веб-сервер, либо приложение. Смешивание уровней почти всегда приводит к дублированию заголовков. Используй whitelist конкретных origins, проверяй их строго через regex с якорями. Логируй Origin из входящих запросов во время отладки — это сэкономит тебе часы.
И ещё один момент, о котором часто забывают: CORS — это защита браузера, не сервера. Прямые запросы через curl или Postman CORS не проверяют. Поэтому CORS не заменяет авторизацию и валидацию на сервере. Это дополнительный слой, не основной. Основная защита — токены, rate limiting и правильная авторизация. Про rate limiting подробнее можно почитать в отдельной статье про защиту от DDoS.
Нужна помощь с настройкой CORS на вашем сайте?
Наши специалисты быстро настроят CORS-заголовки и устранят ошибки кросс-доменных запросов на любом стеке технологий.