Новинка Виртуальный VDS сервер в Финляндии от 390р
Выберите продукт

CORS на практике: правильные заголовки для API и фронта в Nginx и Apache

CORS ломает фронт не из‑за «вредного» браузера, а из‑за неточных заголовков. Практика для Nginx и Apache: простые и preflight‑запросы, белый список Origins, cookies/Authorization, кэширование и отладка.
CORS на практике: правильные заголовки для API и фронта в Nginx и Apache

Когда фронтенд и 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.

Белый список Origin в Nginx и обработка preflight OPTIONS

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.
FastFox VDS
Облачный VDS-сервер
Виртуальные серверы с быстрым запуском и гибкой конфигурацией от 390₽ / мес
Доступные локации
Россия Нидерланды Финляндия

Если вы параллельно наводите порядок с 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>

Apache mod_headers: отражение Origin и ответ 204 на OPTIONS

Авторизация, 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 подскажет конкретное несоответствие заголовков.

Матч‑план внедрения без сюрпризов

  1. Составьте явный белый список Origins и перечень методов/заголовков, которые реально нужны фронту.
  2. Реализуйте настройки сначала на тестовом окружении: Nginx/Apache, preflight с 204, always для заголовков, Vary на месте.
  3. Проверьте сценарии с и без авторизации. Если есть cookies — проверьте, что отклоняется комбинация Allow-Credentials: true и Allow-Origin: * (её быть не должно).
  4. Настройте кэширование preflight: выберите Access-Control-Max-Age и проверьте, как ведут себя промежуточные кэши (если есть CDN/прокси).
  5. Заложите мониторинг: алерты на рост количества 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. Тогда любые изменения будут локальными и без неожиданных побочных эффектов.

Поделиться статьей

Вам будет интересно

Как безопасно выполнить bootstrap MariaDB Galera после полного останова

Как безопасно выполнить bootstrap MariaDB Galera после полного останова

После одновременной остановки всех узлов Galera обычного запуска MariaDB недостаточно. Сначала найдите сервер с последней транзакц ...
CoreDNS в Kubernetes: исправляем CrashLoopBackOff, SERVFAIL и DNS-петлю

CoreDNS в Kubernetes: исправляем CrashLoopBackOff, SERVFAIL и DNS-петлю

Разберём, почему CoreDNS перезапускается или отвечает SERVFAIL, как обнаружить замкнутую пересылку DNS-запросов, проверить доступн ...
Apache Kafka KRaft на VDS: отдельные controller и broker

Apache Kafka KRaft на VDS: отдельные controller и broker

Соберём новый Kafka-кластер без ZooKeeper на шести Linux VDS: три узла controller образуют отказоустойчивый metadata quorum, а три ...