Настройка CORS-заголовков на сайте: полное руководство 2026

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 только в PHP-коде, но Nginx перехватывает OPTIONS-запрос и возвращает ответ раньше, чем PHP его обработает — preflight провалится. Всегда проверяй, что OPTIONS обрабатывается на уровне того же сервера, где стоят CORS-заголовки.

Настройка 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:

💡
Совет по Bitrix: Если ты используешь REST API Bitrix24 или модуль rest в Bitrix CMS — там уже есть встроенная обработка CORS. Не нужно дублировать заголовки, иначе браузер получит два одинаковых заголовка и выдаст ошибку "The 'Access-Control-Allow-Origin' header contains multiple values".

Типичные 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 — это один из элементов защиты, но не единственный. Если хочешь выстроить нормальную защиту сайта, рекомендую изучить настройку Firewall и 10 правил безопасности сайта. CORS защищает от одного вектора атак, firewall — от другого.

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-заголовки и устранят ошибки кросс-доменных запросов на любом стеке технологий.

П
Павел
Веб-разработчик · 10+ лет опыта · Bitrix, WordPress, Laravel

Читайте также