Типичный сценарий современной веб-разработки: фронтенд приложения на 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 полностью совпадают три параметра:
- Протокол (Scheme):
httpиhttps— это разные источники. - Хост (Domain/Host):
my-app.ruиapi.my-app.ru— это разные источники. Даже поддомены считаются изолированными сущностями! - Порт:
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.
Selectel — Корпоративная инфраструктура
Отказоустойчивые дата-центры уровня Tier III, выделенные подсети и гибкие балансировщики нагрузки для распределенных API.
Beget — Простота и отказоустойчивость
Моментальное развертывание Nginx, интуитивная панель управления и быстрая настройка DNS и SSL-сертификатов.