1. 1
  2. 2
  3. 3
  4. 4
  5. 5
  6. 6

# теория · шаг 4 из 6

CORS: почему браузер не показывает ответ

Коротко

CORS - правило браузера. Ответ приходит целиком, но страница не увидит его, пока сервер не разрешит заголовком. Значит, это работа nginx.

  • Разрешение даёт Access-Control-Allow-Origin, и источник в нём ровно один.
  • Флаг always обязателен: без него заголовок теряется на 4xx и 5xx, и ошибка приложения превращается в загадочный «CORS policy».
  • Непростые запросы (PUT, DELETE, свой Content-Type, Authorization) браузер предваряет OPTIONS - ему надо ответить 204 со списком разрешённого.
  • Два одинаковых заголовка от nginx и от бэкенда - отказ. Лишний убирают proxy_hide_header.

Если знаешь, зачем нужен always и почему звёздочка ломает куки - листай до «Теперь сам».

Сначала ответь сам

Фронтенд на shop.local дёргает API на api.shop.local. В консоли красное: «blocked by CORS policy». Разработчик проверяет через curl - приходит 200 с данными. Разрешающий заголовок в конфиге стоит:

/etc/nginx/conf.d/api.shop.local.confhttp server location /api/
add_header Access-Control-Allow-Origin "https://shop.local";

Почему браузер всё равно отказывает?

Потому что в этот момент API отвечает не 200. Ответ 500 или 403 уезжает без этого заголовка: add_header без always работает только на успешных кодах. Разработчик ищет ошибку в настройке доступа, а искать надо ошибку приложения, которую CORS просто накрыл собой.

На что это похоже

Курьер привозит документы из другой организации и оставляет их на вахте. Вахтёр отдаёт конверт получателю только при письменном разрешении отправителя: «этому человеку показать можно». Разрешение выдаёт именно отправитель, а не получатель и не курьер.

Нет бумажки - конверт лежит на вахте нераспечатанным. Он приехал, он целый, внутри всё нужное, а прочитать нельзя. И заметь, кому адресовано разрешение: не курьеру, а конкретному получателю поимённо.

Механизм: два вида запросов

простой запрос: GET, HEAD, простой POST страница GET /api/cart сервер ответ + разрешение непростой: PUT, DELETE, свой заголовок страница OPTIONS /api/cart сервер 204 + что можно PUT /api/cart и только теперь сам запрос Access-Control-Max-Age говорит, сколько помнить разрешение, иначе OPTIONS ходит перед каждым запросом и удваивает их число
Preflight - отдельный запрос методом OPTIONS. Он идёт до основного и должен получить свой ответ.

Источник - тройка «схема плюс хост плюс порт». Любое отличие делает запрос чужим: http://shop.local и https://shop.local - разные источники, shop.local и api.shop.local - тоже.

Правило

/etc/nginx/conf.d/api.shop.local.confhttp server location /api/
if ($request_method = OPTIONS) {
    add_header Access-Control-Allow-Origin  "https://shop.local" always;
    add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS" always;
    add_header Access-Control-Allow-Headers "Authorization, Content-Type" always;
    add_header Access-Control-Max-Age       86400 always;
    add_header Content-Length 0;
    return 204;
}

add_header Access-Control-Allow-Origin "https://shop.local" always;
proxy_pass http://app:8000;

Это тот редкий if, который безопасен: внутри только return и add_header для того же ответа (тринадцатый раздел). Условие пишется в круглых скобках, через пробелы; кавычки у OPTIONS не нужны. Content-Length 0 честно говорит, что тела у ответа нет, а always ему не нужен: 204 и так в списке успешных кодов. Access-Control-Max-Age задан в секундах: 86400 - сутки. Проверено на стенде - preflight отвечает так (-X OPTIONS посылает запрос этим методом):

$  команда
curl -si -X OPTIONS -H 'Origin: https://shop.local' http://api.shop.local/api/cart
↳  выводcurl -si -X OPTIONS -H 'Origin: https://shop.local' http://api.shop.local/api/cart
HTTP/1.1 204 No Content
Server: nginx/1.31.5
Date: Thu, 10 Sep 2026 20:20:30 GMT
Connection: keep-alive
Access-Control-Allow-Origin: https://shop.local
Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS
Access-Control-Allow-Headers: Authorization, Content-Type
Access-Control-Max-Age: 86400
Content-Length: 0

Разбор: что делает always

add_header без always ставит заголовок только на «безопасные» коды - те же, что в пятом разделе решали судьбу expires. Замер по одному и тому же блоку:

код ответа без always с always
200 заголовок есть есть
302 есть есть
403 нет есть
500 нет есть

Отсюда и симптом из начала урока. Пока приложение отвечает 200, всё работает; в день, когда оно отвечает 500, фронтенд получает не «пятисотка на бэкенде», а «CORS policy», и разработчик идёт разбираться не туда.

Разбор: два разрешения хуже одного

Заголовок часто ставит и приложение, и nginx - каждый «на всякий случай». add_header не заменяет чужой заголовок, а добавляет свой, и в ответе едут оба:

$  команда
curl -sI http://api.shop.local/api/cart
↳  выводcurl -sI http://api.shop.local/api/cart
HTTP/1.1 200 OK
Server: nginx/1.31.5
Date: Thu, 10 Sep 2026 20:20:30 GMT
Content-Type: application/octet-stream
Content-Length: 3
Connection: keep-alive
Access-Control-Allow-Origin: https://backend-said.example
Access-Control-Allow-Origin: https://shop.local

Браузер на такое отвечает отказом: разрешение должно быть ровно одно. Решают, кто за это отвечает, и лишнее убирают. Если хозяином назначен nginx, чужой заголовок снимают до того, как он попадёт в ответ:

/etc/nginx/conf.d/api.shop.local.confhttp server location /api/
proxy_hide_header Access-Control-Allow-Origin;
add_header Access-Control-Allow-Origin "https://shop.local" always;

Проверено: остаётся одна строка, и снимается даже заголовок, который бэкенд написал строчными буквами, - регистр в именах заголовков не важен.

Что ломается без этого

Звёздочка и куки. add_header Access-Control-Allow-Origin "*" always; разрешает всем и запрещает главное: с ней браузер не пошлёт куки и авторизацию. Сессия не работает, а в консоли ничего внятного. Для сессий источник называют поимённо и добавляют разрешение на учётные данные:

/etc/nginx/conf.d/api.shop.local.confhttp server location /api/
add_header Access-Control-Allow-Origin      "https://shop.local" always;
add_header Access-Control-Allow-Credentials "true" always;
add_header Vary Origin always;

Здесь источник один, и Vary поставлен на будущее. Vary: Origin обязателен, когда источник не один: без него кеш на пути отдаст ответ, разрешённый одному сайту, посетителю другого (одиннадцатый раздел).

Заголовок съело наследование. Правило второго раздела действует и здесь: свой add_header в дочернем location отменяет все внешние. CORS-заголовок с уровня server пропадает ровно там, где кто-то дописал свою строку.

Зачем это в работе

Источников почти всегда больше одного: боевой домен, админка, стенд. Перечислить их в заголовке нельзя - разрешается ровно один, - поэтому значение собирают картой:

/etc/nginx/nginx.confhttp
map $http_origin $cors_origin {
    default                  "";
    https://shop.local       $http_origin;
    https://admin.shop.local $http_origin;
}
/etc/nginx/conf.d/api.shop.local.confhttp server location /api/
add_header Access-Control-Allow-Origin $cors_origin always;
add_header Vary Origin always;

Пустое значение означает «заголовка не будет вовсе» - проверено на стенде: чужому источнику в ответ уезжает только Vary. Отражать $http_origin без карты - то есть подставлять в ответ присланный Origin как есть - нельзя: это разрешает любому сайту читать твои ответы от имени залогиненного пользователя, а вместе с Allow-Credentials открывает приложение целиком.

Теперь сам

Фронтенд шлёт DELETE /api/cart/42 с заголовком Authorization. В конфиге стоит только add_header Access-Control-Allow-Origin "https://shop.local" always;. В консоли браузера ошибка, в логе nginx нет ни одного DELETE. Почему в логе пусто и чего не хватает?

Браузер до DELETE не дошёл. Метод непростой, значит первым ушёл OPTIONS, и на него никто не ответил разрешением: в логе он есть, а DELETE нет. Не хватает ветки preflight с Allow-Methods и Allow-Headers - причём Authorization обязан быть перечислен в Allow-Headers поимённо, звёздочка там при включённых учётных данных не работает.

Главное

CORS - правило браузера: ответ приходит целиком, но страница его не увидит без Access-Control-Allow-Origin. Флаг always обязателен, иначе на 4xx и 5xx заголовок исчезает и ошибка приложения маскируется под CORS. Непростые запросы предваряются OPTIONS, которому нужен свой ответ 204 со списком методов, заголовков и Max-Age. Разрешение должно быть ровно одно: если его ставят обе стороны, лишнее снимают proxy_hide_header. Звёздочка запрещает куки, поэтому для сессий источник называют поимённо, добавляют Allow-Credentials и Vary: Origin, а несколько источников разруливают картой с белым списком.

Комментарии

Пока нет комментариев. Будь первым!

Оставить комментарий

Комментарий появится после проверки. Email не публикуется. Войти, чтобы не вводить имя каждый раз.