«Access to fetch at ... has been blocked by CORS policy» — пожалуй, одна из самых частых ошибок, с которой сталкивается каждый, кто хоть раз делал запрос с фронтенда на другой домен. Разберём, что это за механизм, почему браузер блокирует запрос, который прекрасно проходит через curl или Postman, и как это чинить на стороне сервера.
Что такое CORS и зачем он нужен
CORS (Cross-Origin Resource Sharing) — это механизм браузерной безопасности, который контролирует, может ли веб-страница с одного домена (origin) делать запросы к ресурсам на другом домене. Без CORS любой сайт мог бы из браузера пользователя слать запросы к произвольным API, используя его авторизационные cookie — классический вектор атаки.
Origin определяется тройкой: протокол + домен + порт. https://example.com и http://example.com — разные origin. https://example.com и https://api.example.com — тоже разные. Даже https://example.com:3000 и https://example.com:8080 — разные origin.
Важный момент, который часто путают: CORS — это ограничение браузера, а не сервера. Сервер всегда может ответить на запрос с любого origin — это браузер на основе заголовков ответа решает, отдавать ли результат JavaScript-коду страницы. Именно поэтому curl и Postman «не видят» проблему: они не применяют политику CORS, это чисто браузерное поведение.
Simple requests vs preflight
Не каждый кросс-доменный запрос обрабатывается одинаково.
Simple request — выполняется напрямую, без предварительной проверки, если соблюдены условия: метод GET, HEAD или POST, и используются только «простые» заголовки (Accept, Accept-Language, Content-Type с одним из трёх базовых значений вроде application/x-www-form-urlencoded).
Preflight request — если запрос не подходит под условия simple (например, метод PUT/DELETE, кастомные заголовки вроде Authorization или Content-Type: application/json), браузер сначала отправляет служебный запрос методом OPTIONS, чтобы спросить у сервера разрешение, и только потом — реальный запрос.
OPTIONS /api/users HTTP/1.1
Origin: https://app.example.com
Access-Control-Request-Method: PUT
Access-Control-Request-Headers: content-type, authorization
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET, POST, PUT, DELETE
Access-Control-Allow-Headers: content-type, authorization
Access-Control-Max-Age: 86400Если сервер не ответил корректными заголовками на preflight — реальный запрос браузер вообще не отправит, а в консоли появится та самая ошибка про CORS policy.
Ключевые заголовки
- Access-Control-Allow-Origin — какой origin может читать ответ. Значение
*разрешает любой origin, но только для запросов без credentials (cookie, авторизационные заголовки). Конкретный origin указывается явно, если нужны cookie/авторизация. - Access-Control-Allow-Methods — какие HTTP-методы разрешены для кросс-доменных запросов.
- Access-Control-Allow-Headers — какие кастомные заголовки клиент может отправлять.
- Access-Control-Allow-Credentials: true — разрешает отправку cookie и заголовков авторизации. При этом
Access-Control-Allow-Originобязан содержать конкретный origin, а не*— браузер заблокирует комбинацию credentials + wildcard origin. - Access-Control-Max-Age — на сколько секунд браузер может закэшировать результат preflight-запроса, чтобы не слать OPTIONS перед каждым обращением.
Полный список текущих заголовков на любом сайте удобно смотреть через анализатор HTTP-заголовков — иногда проблема не в логике CORS, а в том, что заголовок просто не долетает до клиента из-за прокси или CDN, который его обрезает.
Частые ошибки настройки
Wildcard origin вместе с credentials
Access-Control-Allow-Origin: *
Access-Control-Allow-Credentials: trueЭта комбинация недопустима по спецификации — браузер заблокирует запрос, даже если сервер её отдал. Решение: указывать origin явно (можно динамически, на основе разрешённого списка доменов на бэкенде), а не статичную звёздочку.
CORS настроен только для основного API, но не для preflight
Иногда middleware обрабатывает заголовки только для «реальных» запросов (GET/POST), забывая ответить на OPTIONS. В итоге preflight падает с 404 или 401 (потому что роут для OPTIONS не существует или требует авторизацию, которую браузер ещё не передаёт на этапе preflight).
Заголовки добавлены, но не на все ответы
Частый случай — middleware добавляет CORS-заголовки для успешных ответов (200), но не для ошибок (4xx, 5xx). В итоге при ошибке сервера в консоли видна не реальная причина (например, 500 на бэкенде), а маскирующая её ошибка CORS — потому что браузер блокирует доступ к телу ответа без нужных заголовков.
Динамический origin без валидации
Чтобы поддерживать несколько доменов (например, app.example.com и admin.example.com), часто отражают Origin запроса обратно в Access-Control-Allow-Origin без проверки. Это фактически равнозначно wildcard, но с возможностью использовать credentials — серьёзная дыра в безопасности, если список разрешённых origin не валидируется явно по белому списку.
Примеры настройки
Nginx
location /api/ {
set $cors_origin "";
if ($http_origin ~* "^https://(app|admin)\.example\.com$") {
set $cors_origin $http_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, DELETE, OPTIONS' always;
add_header 'Access-Control-Allow-Headers' 'Content-Type, Authorization' always;
if ($request_method = 'OPTIONS') {
add_header 'Access-Control-Max-Age' 86400;
add_header 'Content-Length' 0;
return 204;
}
proxy_pass http://backend;
}Express (Node.js)
const cors = require('cors');
const allowedOrigins = ['https://app.example.com', 'https://admin.example.com'];
app.use(cors({
origin: (origin, callback) => {
if (!origin || allowedOrigins.includes(origin)) {
callback(null, true);
} else {
callback(new Error('Not allowed by CORS'));
}
},
credentials: true,
methods: ['GET', 'POST', 'PUT', 'DELETE'],
allowedHeaders: ['Content-Type', 'Authorization'],
}));Библиотека cors сама обрабатывает preflight-запросы — отдельный роут для OPTIONS писать не нужно.
Как диагностировать проблему
- Откройте вкладку Network в DevTools и найдите запрос — реальный или preflight (
OPTIONS). - Посмотрите статус ответа на preflight. Если 404/401/500 — проблема на уровне роутинга или авторизации, а не CORS как такового.
- Проверьте заголовки ответа: присутствуют ли
Access-Control-Allow-Origin,Access-Control-Allow-Methods,Access-Control-Allow-Headers, соответствуют ли они тому, что реально запрашивает клиент. Удобно прогнать запрашиваемый URL через CORS-чекер, чтобы быстро увидеть, какие заголовки реально приходят в ответе. - Проверьте, не обрезает ли прокси или CDN перед сервером заголовки CORS — иногда они настроены правильно на бэкенде, но теряются на уровне инфраструктуры.
Чек-лист
- Убедиться, что preflight (
OPTIONS) отвечает 2xx и нужными заголовками - Не комбинировать
Access-Control-Allow-Origin: *сAccess-Control-Allow-Credentials: true - Валидировать origin по белому списку, если используется динамическое отражение
- Добавлять CORS-заголовки на все ответы, включая ошибки 4xx/5xx
- Проверить, не теряются ли заголовки на уровне прокси/CDN
- Использовать
Access-Control-Max-Age, чтобы не слать лишние preflight-запросы при каждом обращении
Заключение
CORS — не баг и не препятствие, которое нужно «обойти», а осознанный механизм безопасности браузера. Большинство проблем сводится к нескольким типичным причинам: неотвеченный preflight, неверная комбинация wildcard с credentials или потерянные на прокси заголовки. Проверьте свою текущую конфигурацию через CORS-чекер — обычно проблема находится за пару минут, если знать, на какие заголовки смотреть.