// Engineering Log

HTTPS и веб-сервер на практике: Часть 3 — Что такое CORS: ошибки в консоли браузера и настройка в Nginx

Опубликовано 02.11.2026 · Автор:

// Быстрый маршрут

Эта статья относится к теме Серверы и инфраструктура.

CORS (Cross-Origin Resource Sharing) — механизм, с помощью которого сервер разрешает JavaScript-коду с других сайтов читать свои ответы. По умолчанию браузер этого не позволяет: скрипт на https://app.example.ru может отправить запрос на https://api.example.ru, но прочитать ответ не сможет, если сервер API явно не разрешил. Отсюда ошибки вида «blocked by CORS policy» в консоли, которые появляются, как только фронтенд и API оказываются на разных адресах. Ниже — как устроена проверка, что означает каждая типичная ошибка и как настроить сервер. Сами заголовки HTTP кратко разобраны в статье «Заголовки HTTP: какие бывают и за что отвечают».

Что такое «источник»

Источник (origin) — это схема, хост и порт вместе. Два адреса относятся к одному источнику, только если все три части совпадают:

Адрес страницыАдрес запросаТот же источник?
https://example.ruhttps://example.ru/apiда
https://example.ruhttps://api.example.ruнет — другой хост
https://example.ruhttp://example.ruнет — другая схема
http://localhost:3000http://localhost:8000нет — другой порт

Поддомен — это уже другой источник. Локальная разработка с фронтендом на порту 3000 и API на 8000 — тоже.

Что именно блокирует браузер

Правило одного источника (same-origin policy) запрещает скрипту читать ответы с чужих источников. Отправлять запросы оно в большинстве случаев не запрещает. CORS — способ ослабить это правило для конкретных источников.

Отсюда три важных следствия:

  • CORS защищает пользователя, а не сервер. curl, Postman, скрипт на Python, другой сервер никакого CORS не проверяют и получают ответ всегда. Ограничить доступ к API можно только аутентификацией;
  • «простой» запрос с чужого сайта доходит до сервера и выполняется — браузер лишь не отдаёт ответ скрипту. От подделки таких запросов защищает не CORS, а CSRF-токены и SameSite у cookie («Атаки на уровне HTTP»);
  • ошибка CORS — это ответ сервера без нужных заголовков, а не сетевой сбой. Сервер при этом мог вернуть и 200, и 500: на вкладке Network виден настоящий код.

Простые запросы и предварительный запрос

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

  • метод — GET, HEAD или POST;
  • заголовки — только из короткого разрешённого списка (Accept, Accept-Language, Content-Language, Content-Type и некоторые другие), без Authorization и собственных X-…;
  • Content-Type — только application/x-www-form-urlencoded, multipart/form-data или text/plain.

Любой другой запрос — PUT, PATCH, DELETE, JSON с Content-Type: application/json, запрос с Authorization — требует предварительного запроса (preflight). Браузер сначала отправляет OPTIONS:

OPTIONS /v1/orders HTTP/1.1
Host: api.example.ru
Origin: https://app.example.ru
Access-Control-Request-Method: PUT
Access-Control-Request-Headers: authorization, content-type

Сервер должен ответить кодом 2xx и разрешениями:

HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.example.ru
Access-Control-Allow-Methods: GET, POST, PUT, PATCH, DELETE
Access-Control-Allow-Headers: Authorization, Content-Type
Access-Control-Max-Age: 7200
Vary: Origin

Только после этого уходит настоящий PUT, и его ответ тоже должен содержать Access-Control-Allow-Origin. Access-Control-Max-Age позволяет браузеру кешировать результат предварительного запроса; Chrome ограничивает срок двумя часами.

Заголовки ответа

  • Access-Control-Allow-Origin — какой источник может читать ответ: конкретный (https://app.example.ru) или любой (*). Значение одно: список через запятую не допускается;
  • Access-Control-Allow-Methods, Access-Control-Allow-Headers — разрешённые методы и заголовки, только в ответе на OPTIONS;
  • Access-Control-Allow-Credentials: true — разрешить запросы с cookie и Authorization;
  • Access-Control-Expose-Headers — какие заголовки ответа доступны скрипту. По умолчанию скрипт видит только несколько базовых (Content-Type, Content-Length, Cache-Control и т. п.); чтобы прочитать X-Total-Count или Content-Disposition, их нужно перечислить;
  • Access-Control-Max-Age — кеширование предварительного запроса, в секундах;
  • Vary: Origin — обязательно, если Allow-Origin зависит от Origin запроса. Иначе CDN или браузер закеширует ответ с разрешением для одного сайта и отдаст его другому.

По умолчанию fetch к чужому источнику не отправляет cookie. Чтобы отправлял:

javascript
fetch("https://api.example.ru/v1/me", { credentials: "include" });

Тогда сервер должен:

  • вернуть конкретный источник в Access-Control-Allow-Origin — * вместе с учётными данными запрещён;
  • вернуть Access-Control-Allow-Credentials: true;
  • у cookie должен быть подходящий SameSite: для другого сайта — SameSite=None; Secure. Для поддоменов одного сайта (app.example.ru и api.example.ru — один «сайт» с точки зрения SameSite) хватает Lax.

Токен в заголовке Authorization проще cookie: не нужен credentials: "include", но нужен Authorization в Access-Control-Allow-Headers.

Ошибки в консоли и что они значат

No 'Access-Control-Allow-Origin' header is present on the requested resource Сервер не вернул заголовок. Причины: CORS не настроен; настроен, но ответ пришёл с ошибкой (404, 500, 502), а заголовки добавляются только в успешные ответы — в Nginx не хватает always; запрос ушёл не на тот адрес. Посмотрите на вкладке Network настоящий код ответа.

Response to preflight request doesn't pass access control check: It does not have HTTP ok status Ответ на OPTIONS не 2xx. Чаще всего OPTIONS требует авторизации (401), не поддерживается фреймворком (404, 405) или перенаправляется. Предварительный запрос всегда идёт без cookie и Authorization — сервер должен отвечать на него без проверки доступа.

Redirect is not allowed for a preflight request На OPTIONS пришёл редирект: с http на https, со слеша на без слеша (/v1/orders → /v1/orders/), на страницу входа. Исправьте адрес во фронтенде, чтобы редиректа не было.

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' Запрос с cookie, а в ответе *. Нужен конкретный источник и Allow-Credentials: true.

Request header field authorization is not allowed by Access-Control-Allow-Headers in preflight response Заголовок не перечислен в Access-Control-Allow-Headers. То же для content-type при отправке JSON и для любых собственных заголовков.

Method PUT is not allowed by Access-Control-Allow-Methods in preflight response Метод не перечислен в Access-Control-Allow-Methods.

The 'Access-Control-Allow-Origin' header contains multiple values '…, …', but only one is allowed Заголовок добавляют два слоя сразу — например, и Nginx, и приложение, или и приложение, и CDN. Оставьте CORS в одном месте.

Ответ приходит, но response.headers.get("X-Total-Count") возвращает null Заголовок не перечислен в Access-Control-Expose-Headers.

Настройка в Nginx

Разрешённые источники — по списку через map, чтобы не отражать любой Origin:

nginx
map $http_origin $cors_origin {
    default "";
    "https://app.example.ru"   $http_origin;
    "https://admin.example.ru" $http_origin;
    "~^http://localhost:\d+$"  $http_origin;   # только для тестового стенда
}

server {
    listen 443 ssl;
    server_name api.example.ru;

    location / {
        if ($request_method = OPTIONS) {
            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     "Authorization, Content-Type, X-Request-ID" always;
            add_header Access-Control-Max-Age           7200 always;
            add_header Vary                             Origin always;
            return 204;
        }

        add_header Access-Control-Allow-Origin      $cors_origin always;
        add_header Access-Control-Allow-Credentials true always;
        add_header Access-Control-Expose-Headers    "X-Total-Count, Content-Disposition" always;
        add_header Vary                             Origin always;

        proxy_pass http://127.0.0.1:8080;
    }
}

Пояснения:

  • if с return внутри location — одно из допустимых применений if в Nginx;
  • если источника нет в списке, $cors_origin пустой, и Nginx не выводит заголовок с пустым значением — браузер заблокирует чтение ответа;
  • always нужен, чтобы заголовки были и в ответах 4xx/5xx — иначе вместо понятной ошибки API фронтенд увидит ошибку CORS;
  • если в location появится ещё один add_header, заголовки с уровня server в нём перестанут действовать — держите все заголовки в одном месте.

Настройка в приложении

Если CORS настраивается в приложении, Nginx заголовки не добавляет вовсе:

  • Express: пакет cors — app.use(cors({ origin: ["https://app.example.ru"], credentials: true }));
  • Django: django-cors-headers — CORS_ALLOWED_ORIGINS = ["https://app.example.ru"], CORS_ALLOW_CREDENTIALS = True;
  • FastAPI: CORSMiddleware с allow_origins, allow_credentials, allow_methods, allow_headers;
  • Spring: @CrossOrigin или глобальный CorsConfigurationSource.

Промежуточный слой CORS в приложении должен стоять раньше аутентификации, иначе OPTIONS получит 401.

Как обойтись без CORS

Самое простое решение — не делать запросы кросс-доменными:

  • один домен для фронтенда и API: https://example.ru/ — сайт, https://example.ru/api/ — API через тот же Nginx. Источник один, CORS не нужен, cookie работают без SameSite=None;
  • прокси в разработке: в Vite — server.proxy, в Create React App — поле proxy в package.json. Фронтенд обращается к /api на своём порту, а сервер разработки перенаправляет запросы на API.

Как проверить

Предварительный запрос можно воспроизвести curl’ом:

bash
curl -si -X OPTIONS https://api.example.ru/v1/orders \
  -H 'Origin: https://app.example.ru' \
  -H 'Access-Control-Request-Method: PUT' \
  -H 'Access-Control-Request-Headers: authorization, content-type' \
  | grep -i '^access-control\|^HTTP\|^vary'

И основной — с тем же Origin:

bash
curl -si https://api.example.ru/v1/orders -H 'Origin: https://app.example.ru' | grep -i '^access-control'

С источником не из списка заголовка Access-Control-Allow-Origin быть не должно.

Типичные ошибки

  • Access-Control-Allow-Origin берётся из Origin запроса без проверки по списку — это разрешение для любого сайта, причём вместе с cookie.
  • Access-Control-Allow-Origin: * на API с авторизацией по cookie — не работает и говорит о том, что CORS настраивали наугад.
  • Нет Vary: Origin при ответе, зависящем от Origin, — CDN раздаёт ответ с чужим разрешением.
  • CORS настроен и в Nginx, и в приложении — дублирующийся заголовок.
  • OPTIONS закрыт аутентификацией или ограничением частоты — весь фронтенд ломается при любой нагрузке.
  • Попытка «починить CORS» на фронтенде (mode: "no-cors") — запрос уходит, но ответ становится полностью недоступен скрипту.

// Похожая задача

Если у вас похожая ситуация

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

Тема статьи

Серверы и инфраструктура

VPS, Linux, веб-стек, миграции, хостинг, базы данных и базовая эксплуатация.

Часто с этим приходят

  • Перенести сайт или сервис на новый сервер
  • Настроить Linux, Nginx, базу данных и бэкапы
  • Разобраться, почему всё работает нестабильно

// Следующий шаг

Если вам нужна не только статья, а помощь по этой теме, удобнее сразу перейти в услугу. Главная и подборка материалов остаются рядом.

Открыть услуги

// Reviews

Отзывы по теме

Спасибо Михаилу за отзывчивость. Созвонились, объяснил как сделать самому. Обращаюсь уже второй раз, все супер и оперативно.

Спасибо Михаилу за отзывчивость. Созвонились, объяснил как сделать самому. Обращаюсь уже второй раз, все супер и оперативно.

kireevk

kireevk

Консультация по nginx proxy manager и portainer

25.02.2025 · ★ 5/5

Освоившийся покупатель
apande

apande

Настройка nginx и opencart

07.09.2024 · ★ 5/5

Очень мощный покупатель
kireevk

kireevk

Диагностика Nginx Proxy Manager в docker контейнере и решение проблемы

15.03.2024 · ★ 5/5

Освоившийся покупатель

// Contact

Нужна помощь?

Свяжись со мной и я помогу решить проблему

Написать в Telegram

Отвечаю в течение рабочего дня (03:00–13:00 GMT)

Или оставьте заявку здесь:

Подтвердите, что вы не бот.

Написать и получить быстрый ответ