Руководство 11 мин чтения 2026-10-07

Настройка CORS в Nginx: устранение ошибки No Access-Control-Allow-Origin, обработка предзапросов OPTIONS и подводные камни с credentials

Браузер блокирует запросы к API с ошибкой CORS? Разбираем механику Same-Origin Policy, скрытые ловушки директивы add_header во вложенных location, обязательный флаг always для кодов 4xx/5xx, обработку preflight OPTIONS и динамический allowlist с credentials.

Инженерная редакция SysKit Проверено инженерами SysKit.ru

Типичный сценарий современной веб-разработки: фронтенд приложения на React, Vue или Next.js работает на домене https://my-app.ru, а серверная часть API развернута на поддомене https://api.my-app.ru или отдельном VDS. Разработчик запускает проект, отправляет первый сетевой запрос через fetch() или axios, и браузер намертво блокирует операцию, выводя в консоль красное сообщение:

Access to fetch at 'https://api.my-app.ru/v1/users' from origin 'https://my-app.ru' has been blocked by CORS policy: 
No 'Access-Control-Allow-Origin' header is present on the requested resource.

Попытки скопировать первое попавшееся решение из интернета со строчкой add_header Access-Control-Allow-Origin *; приводят к новым проблемам: перестает работать авторизация по cookie, предзапросы OPTIONS возвращают ошибку 405 Method Not Allowed, а при сбоях бэкенда ошибки 500 маскируются под сбои CORS.

В этой практической инструкции мы детально разберем физику работы CORS, скрытые архитектурные ловушки Nginx и построим эталонную production-конфигурацию для REST API и WebSocket.

1. Механика Same-Origin Policy и почему браузер блокирует API

В основе веб-безопасности лежит концепция Same-Origin Policy (SOP, политика единого источника). Источник (Origin) считается одинаковым только в том случае, если у двух URL полностью совпадают три параметра:

  1. Протокол (Scheme): http и https — это разные источники.
  2. Хост (Domain/Host): my-app.ru и api.my-app.ru — это разные источники. Даже поддомены считаются изолированными сущностями!
  3. Порт: example.ru:80 и example.ru:3000 — это разные источники.

Если JavaScript на странице делает запрос к другому источнику (Cross-Origin Request), браузер не позволяет скрипту прочитать ответ, защищая пользователя от атак типа Cross-Site Request Forgery (CSRF) и кражи приватных данных через вредоносные вкладки.

💡 Парадокс CORS: сервер на самом деле выполняет запрос!

Многие начинающие разработчики думают, что если браузер выдал ошибку CORS, то запрос не дошел до сервера. Это опасное заблуждение. CORS — это ограничение на стороне браузера, а не сервера. В большинстве случаев сервер на VDS успешно получает HTTP-запрос, выполняет запись в базу данных и отправляет ответ 200 OK. Но браузер смотрит на заголовки ответа, не видит разрешающего Access-Control-Allow-Origin и выбрасывает исключение в JavaScript, скрывая тело ответа.

2. Простые запросы против Preflight: почему OPTIONS возвращает 405

Браузер делит все кросс-доменные запросы на две принципиальные категории:

1. Простые запросы (Simple Requests)

Запрос отправляется напрямую без предварительных проверок, если выполняются жесткие условия:

  • Используются только стандартные методы: GET, HEAD или POST.
  • Заголовок Content-Type ограничен только значениями: application/x-www-form-urlencoded, multipart/form-data или text/plain.
  • Отсутствуют нестандартные HTTP-заголовки.

2. Запросы с предварительной проверкой (Preflight Requests)

Любой современный REST API использует заголовок Content-Type: application/json или передает авторизационный токен Authorization: Bearer ..., либо использует методы PUT, PATCH, DELETE.

Такой запрос автоматически переходит в категорию сложных. Браузер обязан сначала отправить служебный предзапрос с методом OPTIONS, передавая два проверочных заголовка:

OPTIONS /v1/users HTTP/1.1
Host: api.my-app.ru
Origin: https://my-app.ru
Access-Control-Request-Method: POST
Access-Control-Request-Headers: authorization, content-type

Браузер спрашивает сервер: «Разрешаешь ли ты клиенту с my-app.ru выполнить метод POST с заголовками authorization и content-type?»

⚠️ Почему возникает ошибка 405 Method Not Allowed

Большинство бэкенд-фреймворков и приложений (Laravel, Express, Django, FastAPI) настраивают роуты только под методы GET и POST. Когда браузер внезапно шлет предзапрос OPTIONS, бэкенд или веб-сервер не находят обработчика и возвращают ошибку 405 Method Not Allowed. В результате основной запрос даже не отправляется. Веб-сервер Nginx обязан перехватывать метод OPTIONS и отвечать на него мгновенным статусом 204 No Content без передачи в медленный бэкенд.

3. Главная ловушка Nginx: потеря директивы add_header во вложенных location

Самая распространенная ошибка, на поиск которой разработчики тратят часы: вы прописали все нужные CORS-заголовки в блоке server, но при обращении к эндпоинтам /api/ они бесследно исчезают.

Причина кроется в официальной логике наследования директивы add_header в архитектуре Nginx:

«Директивы add_header наследуются с предыдущего уровня конфигурации тогда и только тогда, когда на текущем уровне не определено ни одной директивы add_header».

Посмотрим на классический антипаттерн:

server {
    server_name api.my-app.ru;

    # CORS заголовки объявлены на уровне server
    add_header 'Access-Control-Allow-Origin' 'https://my-app.ru' always;
    add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS' always;

    location /api/ {
        # ВОТ ЗДЕСЬ КРОЕТСЯ ЛОВУШКА:
        # Как только мы добавили любой свой заголовок внутри location...
        add_header X-Custom-Trace-Id $request_id;

        # ...ВСЕ директивы add_header с уровня server БОЛЬШЕ НЕ ПРИМЕНЯЮТСЯ!
        proxy_pass http://backend_upstream;
    }
}

Как только Nginx встречает внутри location хотя бы один add_header, он полностью отключает наследование. В результате клиенты получают заголовок X-Custom-Trace-Id, но заголовки CORS стираются, вызывая блокировку в браузере.

⚠️ Правило чистой архитектуры Nginx

Никогда не размазывайте CORS-заголовки между блоками server и location. Либо объединяйте все заголовки в отдельный файл-сниппет (например, /etc/nginx/snippets/cors.conf) и подключайте его через include snippets/cors.conf; строго внутри целевого location, либо объявляйте их локально в блоке обработки API.

4. Проблема ошибок 4xx/5xx: почему обязателен флаг always

Вторая коварная проблема: API прекрасно работает, пока запросы успешны. Но как только бэкенд возвращает ошибку 400 Bad Request, 404 Not Found или падает с 500 Internal Server Error, фронтенд не может прочитать тело ошибки (например, JSON с описанием невалидных полей формы).

Вместо понятного сообщения разработчик видит в консоли браузера все ту же безликую ошибку: «No 'Access-Control-Allow-Origin' header is present».

Почему так происходит?

По умолчанию директива add_header в Nginx добавляет заголовки в ответ только при успешных HTTP-статусах:

200, 201, 204, 206, 301, 302, 303, 304, 307, 308

Если бэкенд ответил кодом 401 Unauthorized, 422 Unprocessable Entity, 500 или сам Nginx вернул 502 Bad Gateway (когда контейнер с бэкендом перезагружается), Nginx молча вырезает все объявленные через add_header заголовки!

Браузер получает ответ 500 без заголовка CORS, считает его небезопасным и намертво блокирует доступ JavaScript к телу ответа.

💡 Спасительный флаг always

Чтобы Nginx гарантированно отправлял CORS-заголовки при абсолютно любых кодах ответа, в конце каждой строчки add_header необходимо обязательно указывать директиву always:

add_header 'Access-Control-Allow-Origin' '$cors_origin' always;

5. Подводные камни с credentials: почему запрещен Access-Control-Allow-Origin: *

Самый популярный «совет» в интернете при возникновении проблем с CORS — выставить звездочку:

add_header 'Access-Control-Allow-Origin' '*' always;

Это работает ровно до тех пор, пока вашему приложению не потребуется аутентификация: авторизационные cookies (например, сессионная cookie PHPSESSID, token) или заголовки HTTP Basic/Digest.

Как только на фронтенде включается опция credentials: 'include' (в API fetch) или withCredentials: true (в библиотеке axios), браузер применяет строгое правило спецификации W3C / WHATWG Fetch Standard:

Правило безопасности: Если запрос отправлен с учетными данными (credentials), ответ сервера ни при каких обстоятельствах не может содержать символ подстановки (*) в заголовке Access-Control-Allow-Origin.

При попытке скомбинировать Origin: * и Credentials: true браузер немедленно заблокирует ответ с ошибкой:

The value of the 'Access-Control-Allow-Origin' header in the response must not be the wildcard '*' 
when the request's credentials mode is 'include'.

Кроме того, сервер обязан явно подтвердить согласие на прием cookies специальным заголовком:

add_header 'Access-Control-Allow-Credentials' 'true' always;

А в заголовке Access-Control-Allow-Origin должно быть указано точное доменное имя запрашивающего сайта (например, https://my-app.ru), а не звездочка.

6. Динамический белый список доменов через директиву map и заголовок Vary

Что делать, если к одному API на VDS обращаются сразу несколько доверенных ресурсов? Например:

  • Основной сайт: https://my-app.ru
  • Панель администратора: https://admin.my-app.ru
  • Локальная среда разработчика: http://localhost:3000

Просто передавать обратно значение $http_origin для абсолютно всех сайтов небезопасно — это равносильно открытию доступа любому фишинговому ресурсу.

Элегантное решение: директива map в Nginx

В блоке http основного конфигурационного файла /etc/nginx/nginx.conf создадим соответствие (маппинг) разрешенных доменов:

# В блоке http (/etc/nginx/nginx.conf):
map $http_origin $cors_origin {
    default "";
    "https://my-app.ru"           $http_origin;
    "https://admin.my-app.ru"     $http_origin;
    "http://localhost:3000"       $http_origin;
    "http://127.0.0.1:3000"       $http_origin;
}

Как это работает: если запрос пришел с доверенного домена из списка, переменная $cors_origin получает значение его Origin. Если с любого постороннего сайта — переменная остается пустой строкой, и заголовок CORS просто не отправляется.

Критическая роль заголовка Vary: Origin

Если вы динамически подставляете домен в Access-Control-Allow-Origin, вы обязаны добавить заголовок Vary:

add_header 'Vary' 'Origin' always;

⚠️ Чем грозит отсутствие заголовка Vary

Если перед вашим сервером стоит CDN (Cloudflare) или включено кэширование в Nginx, без заголовка Vary: Origin прокси закэширует ответ для первого пользователя (например, с Origin: https://my-app.ru) и отдаст этот же заголовок пользователю из панели https://admin.my-app.ru. Браузер администратора увидит несоответствие Origin и заблокирует сессию!

7. Эталонная production-конфигурация Nginx для REST API

Чтобы избежать дублирования кода и исключить проблемы с наследованием, оформим настройки CORS в виде отдельного переиспользуемого сниппета.

Шаг 1: Создание сниппета /etc/nginx/snippets/cors.conf

Создайте файл сниппета на вашем сервере:

# /etc/nginx/snippets/cors.conf

# 1. Заголовки для основных запросов (GET, POST, PUT, DELETE)
# Если Origin не входит в разрешенный map, переменная $cors_origin пуста и заголовок не отправится
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, PATCH, DELETE, OPTIONS' always;
add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization' always;
add_header 'Access-Control-Expose-Headers' 'Content-Length,Content-Range' always;
add_header 'Vary' 'Origin' always;

# 2. Мгновенная обработка предзапросов OPTIONS (Preflight)
if ($request_method = 'OPTIONS') {
    # Кэшируем результат предварительной проверки в браузере на 24 часа (86400 сек)
    add_header 'Access-Control-Max-Age' 86400 always;
    add_header 'Content-Type' 'text/plain charset=UTF-8';
    add_header 'Content-Length' 0;
    return 204;
}

Шаг 2: Подключение в конфигурацию виртуального хоста API

Откройте конфигурацию сайта API (например, /etc/nginx/sites-available/api.conf):

server {
    listen 80;

    location / {
        # Подключаем сниппет CORS строго внутри location
        include snippets/cors.conf;

        # Проксирование запросов в Node.js / Python / PHP-FPM бэкенд
        proxy_pass http://127.0.0.1:3000;
        proxy_http_version 1.1;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}

Проверьте корректность синтаксиса и перезагрузите конфигурацию:

sudo nginx -t
sudo systemctl reload nginx

8. Проверка и отладка CORS через curl и чекер заголовков SysKit

Для проверки корректности настройки CORS не обязательно открывать веб-браузер. Используйте консольную утилиту curl для имитации запросов из различных источников.

1. Тестирование предзапроса OPTIONS (Preflight)

Выполните запрос к вашему API, сымитировав сложный запрос из фронтенда:

curl -I -X OPTIONS https://api.my-app.ru/v1/users   -H "Origin: https://my-app.ru"   -H "Access-Control-Request-Method: POST"   -H "Access-Control-Request-Headers: Authorization, Content-Type"

В ответе вы должны увидеть статус HTTP/2 204 и полный набор разрешающих заголовков:

HTTP/2 204
access-control-allow-origin: https://my-app.ru
access-control-allow-credentials: true
access-control-allow-methods: GET, POST, PUT, PATCH, DELETE, OPTIONS
access-control-allow-headers: DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization
access-control-max-age: 86400
vary: Origin

2. Тестирование с неразрешенного домена (Negative Test)

Попробуйте отправить запрос с постороннего сайта (например, https://hacker.com):

curl -I -X OPTIONS https://api.my-app.ru/v1/users   -H "Origin: https://hacker.com"

В ответе должны отсутствовать заголовки Access-Control-Allow-Origin. Это подтверждает, что директива map надежно блокирует нежелательные источники.

3. Онлайн-проверка HTTP-заголовков в SysKit

Вы также можете быстро просканировать ответы вашего сервера через бесплатный инструмент Анализ HTTP-заголовков онлайн от SysKit, чтобы убедиться в отсутствии дублирования заголовков и наличии необходимых директив безопасности.

🎯 Разворачиваете масштабируемый API на VDS?

Выберите надежный сервер с быстрыми NVMe-дисками, защитой от DDoS-атак и гибким управлением сетевыми портами у проверенных хостинг-провайдеров.

Выбрать скоростной VDS для API

Рекомендуемые VDS-провайдеры

Отказоустойчивые сервера с быстрыми NVMe и каналом до 1 Гбит/с

Выбор редакции
⚡

Timeweb Cloud — Скоростные NVMe VDS

Высокопроизводительные серверы с быстрой дисковой подсистемой и низким пингом для мгновенного ответа backend API.

Конфигурация
1 vCPU 3.3 ГГц • 1 ГБ RAM • 15 ГБ NVMe • Трафик без лимита
Премиум Tier III
🔷

Selectel — Корпоративная инфраструктура

Отказоустойчивые дата-центры уровня Tier III, выделенные подсети и гибкие балансировщики нагрузки для распределенных API.

Конфигурация
1 vCPU • 2 ГБ RAM • 30 ГБ NVMe • 100 Мбит/с
Идеально для API и микросервисов
🟠

Beget — Простота и отказоустойчивость

Моментальное развертывание Nginx, интуитивная панель управления и быстрая настройка DNS и SSL-сертификатов.

Конфигурация
1 vCPU • 1 ГБ RAM • 20 ГБ NVMe • Трафик без лимита

Инструменты SysKit по теме статьи

Бесплатные утилиты для проверки и диагностики вашего сервера

Вопросы и ответы (FAQ)

Популярные вопросы вебмастеров и сисадминов по данной теме

Почему при ошибках 500 или 502 бэкенда в консоли браузера пишется ошибка CORS?
Потому что по умолчанию директива add_header в Nginx добавляет заголовки только при успешных ответах (коды 200, 204, 301, 302). Если бэкенд возвращает 500 или Nginx отдает 502, заголовки CORS не попадают в ответ. Браузер видит ответ без Access-Control-Allow-Origin и блокирует чтение ошибки. Для исправления необходимо обязательно добавлять флаг always в конец каждой директивы add_header.
Почему нельзя использовать Access-Control-Allow-Origin: * вместе с Access-Control-Allow-Credentials: true?
Согласно спецификации W3C/WHATWG CORS, использование символа подстановки (*) совместно с передачей учетных данных (куки, Authorization header) строго запрещено браузерами из соображений безопасности (защита от CSRF и кражи сессий). В этом случае значение Access-Control-Allow-Origin обязано явно совпадать со значением Origin из запроса клиента.
Почему предзапрос OPTIONS возвращает ошибку 405 Method Not Allowed?
Nginx или бэкенд-приложение часто настроены на обработку только методов GET и POST. Когда браузер шлет предварительный запрос OPTIONS (Preflight), сервер не находит обработчика и отдает ошибку 405. Чтобы решить проблему, в Nginx перехватывают метод OPTIONS (if ($request_method = 'OPTIONS')) и немедленно возвращают статус 204 No Content со всеми необходимыми CORS-заголовками.
Почему директивы add_header на уровне server пропадают внутри location?
В архитектуре Nginx директивы add_header не объединяются. Если внутри блока location объявлен хотя бы один собственный add_header, все директивы add_header с вышестоящего уровня (server или http) полностью игнорируются. Решение: выносить общие CORS-заголовки в отдельный сниппет через include или дублировать их в каждом дочернем location.

Читайте также в блоге SysKit

Смежные руководства и полезные технические статьи