Когда фронтенд и API живут на разных источниках (домен, поддомен, порт, схема), браузер включает политику CORS. Любая мелочь в заголовках может привести к загадочным ошибкам в консоли и «тихим» отказам. Ниже — практический разбор, как правильно выставить заголовки в Nginx и Apache для реальных сценариев: SPA на одном домене, API на другом, авторизация по cookies/Authorization, несколько доверенных Origins и кэширование preflight.
Что такое CORS и что именно проверяет браузер
CORS — набор правил, по которым браузер решает, можно ли сайту с одного источника (Origin) обращаться к ресурсу на другом источнике. Ключевое: проверка идёт на стороне браузера, а сервер должен отвечать правильными заголовками. Сами запросы всё равно доходят до сервера; именно браузер, увидев «не те» заголовки, блокирует доступ к ответу приложению.
Важно различать два класса запросов:
- «Простые» (simple): методы GET/HEAD/POST с простыми заголовками и типами контента. Для них нет предварительного запроса, достаточно корректного ответа ресурса с нужными CORS‑заголовками.
- Preflight (OPTIONS): если метод не простой (PUT, DELETE, PATCH и т. п.) или добавлены пользовательские заголовки (например, Authorization, X-Requested-With), браузер сначала шлёт OPTIONS с заголовками
Origin,Access-Control-Request-MethodиAccess-Control-Request-Headers. Только получив разрешение, он отправит основной запрос.
«Разрешение» — это согласованная связка заголовков: кто именно может обращаться, какими методами, с какими заголовками и можно ли передавать cookies/авторизацию.
Минимальный набор заголовков для разных случаев
Сводно, что обычно требуется:
Access-Control-Allow-Origin: конкретный Origin или*(звёздочка). СCredentialsзвёздочку использовать нельзя.Vary: Origin: чтобы кэш и CDN не отдавали один и тот же ответ разным источникам.Access-Control-Allow-Credentials: true— если нужны cookies/Authorization. ТогдаAllow-Originдолжен быть конкретным и соответствовать запросу.- Для preflight:
Access-Control-Allow-Methods,Access-Control-Allow-Headers, опциональноAccess-Control-Max-Age. - Для доступа к нестандартным заголовкам ответа:
Access-Control-Expose-Headers.

Nginx: надёжная схема с whitelisting Origins
В Nginx удобнее всего разрешать фиксированный список источников, динамически отражая пришедший Origin, если он в белом списке. Это помогает избежать случайного разрешения лишних доменов и корректно работать с cookies. Такой подход типично реализуется на собственных серверах или на VDS с root‑доступом.
Белый список Origins и общие заголовки
map $http_origin $cors_origin {
default "";
~^https?://(www\.)?example\.com$ $http_origin;
~^https?://app\.example\.com$ $http_origin;
}
server {
listen 80;
server_name api.example.local;
# Общие CORS-заголовки для обычных запросов
add_header Access-Control-Allow-Origin $cors_origin always;
add_header Access-Control-Allow-Credentials true always;
add_header Vary "Origin" always;
location /api/ {
# Для ответа API можно дополнительно раскрыть заголовки
add_header Access-Control-Expose-Headers "X-Total-Count, ETag" always;
# Обработка preflight OPTIONS
if ($request_method = OPTIONS) {
add_header Access-Control-Allow-Origin $cors_origin always;
add_header Access-Control-Allow-Methods "GET, POST, PUT, PATCH, DELETE, OPTIONS" always;
add_header Access-Control-Allow-Headers "$http_access_control_request_headers" always;
add_header Access-Control-Allow-Credentials true always;
add_header Access-Control-Max-Age 86400 always;
add_header Vary "Origin, Access-Control-Request-Headers, Access-Control-Request-Method" always;
return 204;
}
# Дальше — ваша логика API, например:
# proxy_pass http://backend;
}
}
Комментарии к настройке:
mapбезопасно «провалит» пустое значение, если источник не из списка. В этом случае заголовокAccess-Control-Allow-Originпросто не будет отправлен.alwaysуadd_headerгарантирует заголовки не только при 2xx, но и для 3xx/4xx/5xx. Это важно, иначе браузер заблокирует даже корректную ошибку API (например, 401).- Вариант с
$http_access_control_request_headersотражает запрошенные заголовки в preflight. Для жёсткого контроля замените на белый список, например"Content-Type, Authorization, X-Requested-With". Vary: Originи в preflight, и в обычных ответах помогает прокси и CDN возвращать корректные варианты для разных источников.
Сценарий без авторизации: разрешить всем
Если API публичный и не использует cookies/Authorization, можно открыть для всех и упростить конфигурацию:
server {
listen 80;
server_name public.example.local;
add_header Access-Control-Allow-Origin * always;
add_header Vary "Origin" always;
location /public/ {
if ($request_method = OPTIONS) {
add_header Access-Control-Allow-Origin * always;
add_header Access-Control-Allow-Methods "GET, POST, OPTIONS" always;
add_header Access-Control-Allow-Headers "$http_access_control_request_headers" always;
add_header Access-Control-Max-Age 86400 always;
return 204;
}
# proxy_pass http://public-backend;
}
}
Помните: как только понадобятся cookies или заголовок Authorization, надо отказаться от * и перейти к отражению конкретного Origin плюс добавить Access-Control-Allow-Credentials: true.
Типичные ошибки в Nginx
- Отсутствует обработка OPTIONS. Браузер делает preflight, получает 405/404 без заголовков и блокирует основной запрос. Лечится явным
return 204с нужными заголовками. - Ставите
*вместе сAllow-Credentials: true. Браузер игнорирует такой ответ. Нужно отражать конкретный Origin. - Нет
Vary: Origin. Кэширующие прокси начнут склеивать ответы для разных источников, появятся «мистические» баги. - Заголовки только в
location, но редиректы/ошибки формируются уровнем выше. Добавляйте ключевые заголовки и на уровнеserver. - 301/302 на preflight. Любой редирект на OPTIONS — почти всегда проблема. Возвращайте 204 прямо на исходный URL.
Если вы параллельно наводите порядок с HTTPS и редиректами, пригодится материал про перенос и HSTS — перенос на HTTPS, 301 и HSTS — перенос на HTTPS, 301 и HSTS. Для управления конфигом на сервере через панель посмотрите обзор панелей для VDS — обзор популярных панелей для VDS.
Apache: mod_headers + точечные условия
В Apache оптимальный путь — модуль mod_headers и выражения 2.4 для проверки и отражения Origin. Для preflight полезно отдать 204 и полный набор заголовков. На shared-хостинге часто хватает .htaccess, а на серверах — конфиг виртуального хоста.
Виртуальный хост или .htaccess: whitelisting Origins
# Убедитесь, что загружены модули: headers, rewrite (при необходимости)
# Разрешаем два источника и отражаем пришедший Origin
<If "req('Origin') =~ m#^https?://(www\.)?example\.com$# || req('Origin') =~ m#^https?://app\.example\.com$#">
Header always set Access-Control-Allow-Origin "%{Origin}i"
Header always set Access-Control-Allow-Credentials "true"
Header always merge Vary "Origin"
</If>
# Для конкретного префикса API
<Location "/api/">
# Разрешаем просматривать определённые заголовки ответа
Header always set Access-Control-Expose-Headers "X-Total-Count, ETag"
# Обработка preflight OPTIONS
<If "req('REQUEST_METHOD') == 'OPTIONS'">
Header always set Access-Control-Allow-Methods "GET, POST, PUT, PATCH, DELETE, OPTIONS"
Header always set Access-Control-Allow-Headers "%{Access-Control-Request-Headers}i"
Header always set Access-Control-Max-Age "86400"
Header always merge Vary "Origin, Access-Control-Request-Headers, Access-Control-Request-Method"
Require all granted
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteRule ^ - [R=204]
</IfModule>
</If>
</Location>
Пояснения:
Header always ...гарантирует отправку заголовков на любые коды ответа.%{Origin}iбезопасно отражает пришедший Origin только если условие<If ...>прошло. В противном случае заголовки не выставляются.merge Varyбережно объединяет значения с уже существующимVary, не перетирая его.- В preflight отражаем запрошенные заголовки через
%{Access-Control-Request-Headers}iлибо подставляем белый список вручную. - Ответ на OPTIONS делаем кодом 204, чтобы браузер не воспринимал его как редирект или ошибку.
.htaccess для статической раздачи
Если у вас нет доступа к конфигу виртуального хоста, базовые заголовки можно выставить в .htaccess. Но имейте в виду: логика preflight с кодом 204 через RewriteRule может зависеть от структуры директорий и других правил.
<IfModule mod_headers.c>
<If "req('Origin') =~ m#^https?://(www\.)?example\.com$#">
Header always set Access-Control-Allow-Origin "%{Origin}i"
Header always set Access-Control-Allow-Credentials "true"
Header always merge Vary "Origin"
</If>
<If "req('REQUEST_METHOD') == 'OPTIONS'">
Header always set Access-Control-Allow-Methods "GET, POST, OPTIONS"
Header always set Access-Control-Allow-Headers "%{Access-Control-Request-Headers}i"
Header always set Access-Control-Max-Age "600"
</If>
</IfModule>

Авторизация, cookies и нюансы безопасности
Как только включаете Access-Control-Allow-Credentials: true, вы обязаны:
- Возвращать точный
Access-Control-Allow-Origin, совпадающий с присланнымOrigin. Никаких*. - На фронте использовать
fetchсcredentials: 'include'или настроить XHR на передачу cookies. - Убедиться, что сторонние домены действительно должны иметь доступ к данным пользователя. Подумайте о CSRF и используйте токены/двойные куки.
Частое «облегчение» — отражать Access-Control-Allow-Headers как присланные. Это удобно, но открывает поверхность атак для неожиданных заголовков. В идеале держите белый список и обновляйте его при необходимости.
Кэширование preflight и заголовок Vary
Access-Control-Max-Age позволяет браузеру кэшировать результат preflight. Баланс: от десятков минут до суток. Слишком маленькое значение — лишние OPTIONS, слишком большое — сложнее оперативно отозвать доступ (например, при инциденте). Для публичных API без авторизации разумно ставить 1–24 часа. Для приватных с cookies — консервативнее, 5–30 минут.
Не забывайте про Vary для preflight: запросы отличаются Access-Control-Request-Method и Access-Control-Request-Headers. Добавьте их в Vary, чтобы кэши не путали результаты.
Мульти‑поддомены и шаблоны Origin
Распространённый кейс — нужно открыть доступ для всех поддоменов одного домена. В Nginx используйте регулярку в map, в Apache — выражение expr с регулярным выражением. Важно не допустить подстановок наподобие evil.example.com.attacker.tld: регулярка должна фиксировать границы домена.
Пример для Nginx в map: ~^https?://([a-z0-9-]+\.)*example\.com$. Для Apache аналогичный шаблон в m#^https?://([a-z0-9-]+\.)*example\.com$#.
Диагностика: чем проверять прямо сейчас
Начните с простого curl для preflight и обычного запроса. Смотрите, совпадает ли Access-Control-Allow-Origin с присланным Origin, приходят ли заголовки Allow-Methods/Allow-Headers и нет ли редиректов.
# Preflight на эндпоинт
curl -i -X OPTIONS -H "Origin: https://app.example.com" -H "Access-Control-Request-Method: PUT" -H "Access-Control-Request-Headers: Authorization, Content-Type" https://api.example.com/api/resource
# Обычный запрос с Origin
curl -i -H "Origin: https://app.example.com" https://api.example.com/api/resource
В браузере откройте вкладку Network: у preflight статус должен быть 204 (или 200) без редиректов, а у основного ответа — ожидаемые заголовки CORS. Вкладка Console подскажет конкретное несоответствие заголовков.
Матч‑план внедрения без сюрпризов
- Составьте явный белый список Origins и перечень методов/заголовков, которые реально нужны фронту.
- Реализуйте настройки сначала на тестовом окружении: Nginx/Apache, preflight с 204,
alwaysдля заголовков,Varyна месте. - Проверьте сценарии с и без авторизации. Если есть cookies — проверьте, что отклоняется комбинация
Allow-Credentials: trueиAllow-Origin: *(её быть не должно). - Настройте кэширование preflight: выберите
Access-Control-Max-Ageи проверьте, как ведут себя промежуточные кэши (если есть CDN/прокси). - Заложите мониторинг: алерты на рост количества OPTIONS, 4xx/5xx на preflight и на основные методы.
Чеклист частых ловушек
- Забыли
Vary: Origin— получаете «рандом» поведение через кэш. - Возвращаете 301/302 на OPTIONS — браузер не продолжает основной запрос.
- Выставляете заголовки только на 2xx — ошибки API становятся невидимыми для фронта.
- Используете несколько Origins через запятую в
Allow-Origin— спецификация запрещает, нужен ровно один. - Хардкодите
Allow-Headersи забываете про новый заголовок на фронте — preflight начинает падать. - Отражаете любые заголовки запроса — удобно, но небезопасно; лучше белый список.
Итоги
Корректная реализация CORS — это не «поставить одну звёздочку». Нужна согласованная конфигурация: точный Access-Control-Allow-Origin, режим с cookies через Allow-Credentials, аккуратная обработка preflight OPTIONS с Allow-Methods/Allow-Headers и кэшированием, плюс Vary для работы с прокси. На практике этого достаточно, чтобы фронт и API стабильно общались между собой, а безопасность и кэширование вели себя предсказуемо.
Если у вас есть нестандартные требования (много доменов, разные политики для путей, сочетание API и статики), разбивайте конфигурацию на зоны ответственности: общий слой по умолчанию и переопределения в нужных локациях/секциях, не забывая про always и Vary. Тогда любые изменения будут локальными и без неожиданных побочных эффектов.


