CORS (Cross-Origin Resource Sharing, «совместное использование ресурсов между разными источниками») это механизм на HTTP-заголовках, который позволяет серверу указать браузеру, каким чужим сайтам можно читать его ответы. Если такого разрешения в ответе нет, браузер блокирует доступ вашего JavaScript к данным и пишет в консоль ошибку CORS. Исправляют её на стороне сервера, к которому идёт запрос, а не в браузере.
Примеры ниже проверены на Node.js 24.14, Express 5.2, Flask 3.1 и Vite 8.3. Тексты ошибок сняты на стенде: страница на http://localhost:3000, тестовый API на http://localhost:4000, браузер Edge 154. Сообщения формирует движок Chromium, общий с Chrome.
- Источник и политика same-origin
- Как работает CORS: простой запрос
- Предварительный запрос (preflight)
- Заголовки CORS
- Куки и credentials
- Настройка CORS на сервере
- Node.js без библиотек
- Express
- Flask
- Прокси на время разработки
- Ошибка CORS: что значит сообщение в консоли и как исправить
- No ‘Access-Control-Allow-Origin’ header is present
- Response to preflight request doesn’t pass access control check
- Value that is not equal to the supplied origin
- Contains multiple values
- Wildcard ‘*’ и credentials
- Not allowed by Access-Control-Allow-Headers или Allow-Methods
- Redirect is not allowed for a preflight request
- Страница открыта как файл
- Почему «отключить CORS» не решение
- Частые вопросы
- CORS policy: что это значит?
- Почему в Postman и curl всё работает, а в браузере ошибка?
- Почему OPTIONS уходит перед каждым запросом?
- Нужен ли CORS, если фронтенд и бэкенд на одном домене?
Источник и политика same-origin
Источник (origin) складывается из трёх частей: схемы (протокола), домена и порта. Путь в него не входит. Если отличается хоть одна часть, источники разные. Возьмём страницу https://example.com:
| Адрес запроса | Источник тот же? | Почему |
|---|---|---|
https://example.com/api/users | да | отличается только путь |
https://example.com:8443/api/users | нет | другой порт |
http://example.com/api/users | нет | другой протокол |
https://api.example.com/users | нет | другой домен, хотя это поддомен |
Политика одинакового источника (same-origin policy) запрещает скрипту читать ответы с чужого источника. Встраивать чужое при этом можно: картинки, скрипты, стили, iframe. Отправить форму на другой домен тоже можно. Под запрет попадает чтение, и CORS используется там, где страница получает чужие данные: в fetch() и XMLHttpRequest, в веб-шрифтах из @font-face, в текстурах WebGL и в картинках, которые рисуют на canvas через drawImage().
CORS ослабляет этот запрет точечно. Владелец API перечисляет источники, которым доверяет, и браузер открывает доступ к ресурсу только им.
Как работает CORS: простой запрос
Браузер различает два типа запросов: простые и с предварительной проверкой. К запросу из fetch или XMLHttpRequest на чужой источник он добавляет заголовок Origin со своим источником. Если сервер согласен, в ответе стоит заголовок Access-Control-Allow-Origin. Браузер сравнивает одно с другим и решает, отдать ли ответ скрипту.
Запрос считается простым, если выполнены три главных условия (ещё два касаются потока ReadableStream в теле и обработчиков на xhr.upload):
- метод GET, HEAD или POST;
- вручную заданы только заголовки Accept, Accept-Language, Content-Language, Content-Type и Range;
- Content-Type один из трёх:
application/x-www-form-urlencoded,multipart/form-dataилиtext/plain.
Простой запрос уходит сразу, без предварительной проверки. Отсюда следствие для безопасности: CORS защищает данные в браузере пользователя, сам сервер он не защищает. На стенде страница отправила POST с text/plain на маршрут /no-header отдельного тестового API, где заголовков CORS нет (это не сервер из раздела про Node.js ниже, у него свой формат журнала). Браузер ответ заблокировал, а в журнале тестового API запрос есть:
POST /no-header Origin=http://localhost:3000

Если бы этот обработчик удалял запись, запись бы удалилась. Защиту от CSRF (cross-site request forgery, подделки межсайтовых запросов) и проверку прав CORS не заменяет.
Предварительный запрос (preflight)
Запрос с методом PUT или DELETE, с заголовком Authorization или с Content-Type: application/json простым уже не считается. Перед ним браузер сначала отправляет предварительный запрос (preflight request) методом OPTIONS и спрашивает разрешение:
OPTIONS /api/items HTTP/1.1
Origin: http://localhost:3000
Access-Control-Request-Method: PUT
Access-Control-Request-Headers: content-type, authorization
Ответ должен прийти с кодом из диапазона 200-299, например 200 или 204, и перечислить необходимые методы и заголовки. Так ответил сервер из раздела про Node.js ниже, вывод снят с помощью curl (сокращён: убраны Date, Connection и Keep-Alive):
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: http://localhost:3000
Access-Control-Allow-Credentials: true
Vary: Origin
Access-Control-Allow-Methods: GET, POST, PUT, DELETE
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Max-Age: 600
Только после такого ответа уходит основной запрос. Если проверка не прошла, браузер его не отправляет. Без Access-Control-Max-Age браузер помнит результат проверки 5 секунд и потом шлёт OPTIONS снова. Большое значение браузеры урезают: Chromium с 76-й версии держит не дольше 2 часов (7200 секунд), Firefox не дольше 24 часов.
Предварительный запрос никогда не несёт куки и токены. Если OPTIONS проходит через проверку авторизации, сервер может вернуть 401, и тогда основной запрос не уйдёт вовсе.

Заголовки CORS
| Заголовок | Кто отправляет | Что значит |
|---|---|---|
Origin | браузер | источник страницы, с которой идёт запрос |
Access-Control-Request-Method | браузер в preflight | каким методом пойдёт основной запрос |
Access-Control-Request-Headers | браузер в preflight | какие заголовки он добавит |
Access-Control-Allow-Origin | сервер | указывает, кому дать доступ к ответу: конкретный источник или * |
Access-Control-Allow-Methods | сервер в preflight | разрешённые HTTP-методы |
Access-Control-Allow-Headers | сервер в preflight | разрешённые заголовки запроса |
Access-Control-Max-Age | сервер в preflight | сообщает, сколько секунд кешировать проверку |
Access-Control-Allow-Credentials | сервер | отдать ли скрипту ответ на запрос с куки |
Access-Control-Expose-Headers | сервер | какие заголовки ответа отдать скрипту |
Vary: Origin | сервер | ответ зависит от Origin, кешировать раздельно |
Без Access-Control-Expose-Headers скрипт видит только Cache-Control, Content-Language, Content-Length, Content-Type, Expires, Last-Modified и Pragma. Свой X-Total-Count для пагинации придётся перечислить явно.
Vary: Origin нужен, когда в ответ подставляется разный источник в зависимости от запроса. Без него браузер или прокси может закешировать ответ без Access-Control-Allow-Origin и потом отдать его на CORS-запрос.
Куки и credentials

По умолчанию fetch на чужой домен куки не отправляет. Чтобы отправлял и мог получать ответ с данными пользователя, нужен credentials: "include", и тогда правила строже:
Access-Control-Allow-Originсодержит точный источник, звёздочка не работает;Access-Control-Allow-Credentials: true, строго в нижнем регистре:Trueбраузер не примет;- звёздочка в Allow-Methods, Allow-Headers и Expose-Headers тоже перестаёт работать, нужны явные списки.
Даже при верных заголовках на куки действуют настройки сторонних куки в браузере.
Мы бы не советовали отражать в ответе любой пришедший Origin вместе с Access-Control-Allow-Credentials: true. Тогда любой сайт сможет прочитать данные пользователя, если браузер отправит его куки. Держите список разрешённых источников.
Настройка CORS на сервере
Node.js без библиотек
Пример на встроенном модуле node:http разрешает доступ двум источникам, отвечает на предварительный запрос и принимает куки:
// server.mjs: API на порту 4000, Node.js 24, без зависимостей
import http from "node:http";
const ALLOWED = new Set(["http://localhost:3000", "https://app.example.com"]);
http.createServer((req, res) => {
const origin = req.headers.origin;
console.log(req.method, req.url, "Origin:", origin ?? "нет");
if (origin && ALLOWED.has(origin)) {
res.setHeader("Access-Control-Allow-Origin", origin);
res.setHeader("Access-Control-Allow-Credentials", "true");
}
res.setHeader("Vary", "Origin");
if (req.method === "OPTIONS") {
res.writeHead(204, {
"Access-Control-Allow-Methods": "GET, POST, PUT, DELETE",
"Access-Control-Allow-Headers": "Content-Type, Authorization",
"Access-Control-Max-Age": "600",
});
return res.end();
}
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ ok: true, method: req.method }));
}).listen(4000, () => console.log("API: http://localhost:4000"));
Страница с разрешённого источника отправляет PUT с JSON и токеном:
fetch("http://localhost:4000/api/items", {
method: "PUT",
credentials: "include",
headers: { "Content-Type": "application/json", "Authorization": "Bearer test" },
body: JSON.stringify({ name: "test" }),
})
.then((r) => r.json())
.then((data) => console.log("Ответ:", JSON.stringify(data)))
.catch((e) => console.log("Ошибка:", String(e)));
В консоли браузера и в терминале сервера:
Ответ: {"ok":true,"method":"PUT"}
API: http://localhost:4000
OPTIONS /api/items Origin: http://localhost:3000
PUT /api/items Origin: http://localhost:3000
Сначала пришёл OPTIONS, потом основной PUT. Как устроены then и await вокруг fetch, мы разбирали в статье про async/await в JavaScript.
Тот же сервер через curl с чужим источником (вывод сокращён так же):
$ curl -i http://localhost:4000/api/items -H "Origin: https://evil.example"
HTTP/1.1 200 OK
Vary: Origin
Content-Type: application/json
Transfer-Encoding: chunked
{"ok":true,"method":"GET"}
Данные пришли: curl правила CORS не проверяет, это делает только браузер.

Express
Пакет cors делает то же короче (Express 5.2, cors 2.8):
const express = require("express");
const cors = require("cors");
const app = express();
app.use(cors({
origin: ["http://localhost:3000", "https://app.example.com"],
credentials: true,
maxAge: 600,
}));
app.use(express.json());
app.put("/api/items", (req, res) => res.json({ ok: true, name: req.body.name }));
app.listen(4000);
Если allowedHeaders не задан, пакет повторяет в Access-Control-Allow-Headers те заголовки, о которых спросил браузер. Подключайте cors() до маршрутов и до проверки авторизации, иначе OPTIONS до него не дойдёт.
Flask
Flask-CORS это расширение Flask, которое добавляет те же заголовки (Flask 3.1, Flask-CORS 6.0):
from flask import Flask, jsonify, request
from flask_cors import CORS
app = Flask(__name__)
CORS(app, origins=["http://localhost:3000"], supports_credentials=True, max_age=600)
@app.put("/api/items")
def put_item():
return jsonify(ok=True, name=request.json["name"])
if __name__ == "__main__":
app.run(port=4000)
На предварительный запрос расширение на стенде вернуло 200 с нашим источником в Access-Control-Allow-Origin и с Access-Control-Allow-Credentials. На запрос с чужого домена заголовок Access-Control-Allow-Origin не пришёл.
Прокси на время разработки

Если фронтенд и API расходятся по портам только во время разработки, на Vite хватит встроенного прокси: браузер обращается к своему же источнику, и CORS не участвует.
// vite.config.js
import { defineConfig } from "vite";
export default defineConfig({
server: {
proxy: {
"/api": "http://localhost:4000",
},
},
});
Теперь fetch("/api/items") со страницы на порту 5173 уходит на порт 4000. На проде такой прокси не работает: там его роль берёт nginx или бэкенд.
Ошибка CORS: что значит сообщение в консоли и как исправить
JavaScript о причине не узнаёт: в Chromium fetch падает с TypeError: Failed to fetch, и всё. Та же ошибка бывает и без CORS: если рядом в консоли net::ERR_CONNECTION_REFUSED, сервер API не запущен или слушает другой порт. Как найти его процесс, описано в статье про список процессов в Linux. Определить причину ошибки CORS можно по консоли браузера и вкладке Network. Chrome и Edge пишут Access to fetch at '...' from origin '...' has been blocked by CORS policy: и дальше причину. Firefox пишет Cross-Origin Request Blocked и причину в скобках после Reason:. Ниже сообщения Chromium, каждое воспроизведено на стенде.
No ‘Access-Control-Allow-Origin’ header is present
Access to fetch at 'http://localhost:4000/no-header' from origin 'http://localhost:3000'
has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present
on the requested resource.
Причины: сервер вообще не ставит заголовок, ваш источник не попал в список разрешённых или обработчик упал (на стенде ответ 500 без заголовка дал ровно это сообщение). Поэтому сначала откройте вкладку Network и посмотрите код ответа и журнал приложения.
В настройках nginx add_header по умолчанию срабатывает только на кодах 200, 201, 204, 206, 301, 302, 303, 304, 307 и 308. На 401, 404 и 500 заголовка не будет, пока не добавить always:
add_header Access-Control-Allow-Origin "https://app.example.com" always;
add_header Vary Origin always;
Если в location есть хоть один свой add_header, заголовки из внешнего блока туда не наследуются.

Response to preflight request doesn’t pass access control check
Response to preflight request doesn't pass access control check: It does not have HTTP ok status.
На OPTIONS пришёл код вне 200-299, на стенде это был 405. Обычно это значит: маршрут описан только для POST или PUT, а OPTIONS никто не обрабатывает, или предварительный запрос отсекла авторизация. Отвечайте на OPTIONS кодом 204 до проверки токена. Если вместо этого в строке стоит No 'Access-Control-Allow-Origin' header is present, на OPTIONS не пришёл заголовок, и код ответа может быть любым.
Value that is not equal to the supplied origin
The 'Access-Control-Allow-Origin' header has a value 'http://localhost:3000/'
that is not equal to the supplied origin.
На стенде хватило косой черты в конце. По спецификации браузер сравнивает значение заголовка с источником страницы побайтно, поэтому не совпадут и https вместо http, другой порт или лишний www..
Contains multiple values
The 'Access-Control-Allow-Origin' header contains multiple values '*, *', but only one is allowed.
Заголовок добавили дважды: например, и nginx, и приложение. Оставьте CORS в одном месте.
Wildcard ‘*’ и credentials
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'.
Запрос идёт с credentials: "include", а в ответе звёздочка. Нужен точный источник. Если источник верный, но нет второго заголовка, сообщение другое: The value of the 'Access-Control-Allow-Credentials' header in the response is '' which must be 'true' when the request's credentials mode is 'include'.
Not allowed by Access-Control-Allow-Headers или Allow-Methods
Request header field authorization is not allowed by Access-Control-Allow-Headers in preflight response.
Method DELETE is not allowed by Access-Control-Allow-Methods in preflight response.
Заголовка или метода нет в списке ответа на preflight. Проблема решается одной строкой: добавьте его в Access-Control-Allow-Headers или Access-Control-Allow-Methods.
Redirect is not allowed for a preflight request
Ответ на OPTIONS оказался редиректом: на стенде это был 301. Так бывает, например, когда http перенаправляет на https или /api/items на /api/items/. Запрашивайте конечный адрес сразу.
Страница открыта как файл
Access to fetch at 'file:///.../data.json' from origin 'null' has been blocked by CORS policy:
Cross origin requests are only supported for protocol schemes: ... http, https ...
HTML открыли двойным щелчком, и у страницы нет источника, отсюда origin 'null'. Запустите любой локальный сервер, например python -m http.server 8000, и откройте http://localhost:8000.

Почему «отключить CORS» не решение
Расширение браузера или Chrome с флагом --disable-web-security (он работает только вместе с --user-data-dir) снимают ошибку только на вашем компьютере. У пользователей сайт так и останется сломанным.
mode: "no-cors" ошибку убирает, но и данных не даёт. На стенде такой запрос вернул status 0, type: "opaque" и пустое тело. Прочитать ответ нельзя, это режим для запросов, где ответ не нужен.
Публичные CORS-прокси пропускают через чужой сервер ваши запросы, а с ними токены. Их мы бы не использовали даже для учебного проекта. Если API чужой и настроить его нельзя, ходите к нему со своего бэкенда.
Звёздочка в Access-Control-Allow-Origin без куки безопасна для открытых данных, которые и так можно скачать curl из любой точки сети: курсы валют, публичный каталог. Для личного кабинета, админки и всего, что стоит за входом или во внутренней сети, нужен список источников.
Частые вопросы
CORS policy: что это значит?
Это правила, которые сервер передаёт браузеру заголовками Access-Control-*: каким источникам, методам и заголовкам разрешён доступ к ресурсу. Фраза blocked by CORS policy в консоли означает, что ответ этим правилам не соответствует и браузер скрыл его от скрипта.
Почему в Postman и curl всё работает, а в браузере ошибка?
Postman, curl и серверный код политику одинакового источника не применяют. Заголовки CORS читает только браузер, поэтому они нужны лишь для запросов со страниц.
Почему OPTIONS уходит перед каждым запросом?
В ответе нет Access-Control-Max-Age, и браузер помнит проверку 5 секунд. Поставьте, например, 600: проверка будет повторяться раз в 10 минут. Кеш хранится отдельно для каждого URL, так что первый запрос к новому адресу снова начнётся с OPTIONS. С галочкой Disable cache во вкладке Network кеш не работает: на стенде два PUT подряд дали два OPTIONS, без галочки один.
Нужен ли CORS, если фронтенд и бэкенд на одном домене?
Нет, если совпадают схема, хост и порт. site.ru и api.site.ru уже разные источники, и для них CORS настраивать нужно.








