Если открыть DevTools → Network перед fetch с заголовком Authorization — иногда там перед нашим запросом появляется второй, который мы не отправляли: OPTIONS на тот же URL. Это и есть preflight, «предполётная проверка».

Зачем

Часть запросов считается «опасной»: меняют состояние сервера (DELETE), несут нестандартные заголовки (Authorization, кастомные X-*) или формат тела, отличный от привычных HTML-форм. Перед таким запросом браузер хочет спросить у сервера: «а ты вообще разрешаешь такие запросы с моего origin?».

И отправляет preflight — пустой OPTIONS-запрос на тот же URL, в котором описывает намерение:

OPTIONS /api/posts/42
Origin: https://fruntend.com
Access-Control-Request-Method: DELETE
Access-Control-Request-Headers: authorization, content-type

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

HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://fruntend.com
Access-Control-Allow-Methods: GET, POST, PUT, PATCH, DELETE
Access-Control-Allow-Headers: authorization, content-type
Access-Control-Max-Age: 86400

Браузер видит разрешение и только после этого отправляет настоящий DELETE. Без разрешения — в JS прилетает CORS-ошибка, настоящий запрос даже не уходит.

Когда preflight не нужен (simple request)

Без preflight браузер шлёт запрос напрямую, если выполняются все условия:

  1. Метод — GETHEAD или POST.
  2. Из заголовков — только «CORS-safelisted»: AcceptAccept-LanguageContent-LanguageContent-Type с ограниченным набором значений (см. п. 3).
  3. Если есть Content-Type — одно из: application/x-www-form-urlencodedmultipart/form-datatext/plain.
  4. Никаких слушателей на upload-событиях.

Чаще всего не попадает в simple request:

  • любой PUTPATCHDELETE — всегда preflight;
  • POST с Content-Type: application/json — preflight (тип JSON не в безопасном списке);
  • любой запрос с Authorization или кастомным X-* — preflight.

То есть для большинства настоящих API-запросов preflight будет лететь автоматически.

Заголовки preflight по полочкам

На стороне запроса (отправляет браузер сам):

  • Origin — откуда страница.
  • Access-Control-Request-Method — какой метод хочется отправить дальше.
  • Access-Control-Request-Headers — какие нестандартные заголовки хочется отправить.

На стороне ответа (отвечает сервер):

  • Access-Control-Allow-Origin — разрешённый origin (точное значение или *).
  • Access-Control-Allow-Methods — какие методы можно.
  • Access-Control-Allow-Headers — какие заголовки можно.
  • Access-Control-Allow-Credentials — true, если разрешено отправлять куки. Об этом ниже.
  • Access-Control-Max-Age — сколько секунд можно кэшировать preflight (об этом ниже).

Кэширование preflight

Без оптимизации каждый защищённый запрос превращается в два: сначала OPTIONS, потом сам запрос. Это удваивает количество ходок в сеть.

Лечится заголовком Access-Control-Max-Age: сервер говорит «разрешение действует столько-то секунд», и браузер запоминает. В этот срок preflight для пары (метод, путь, заголовки) больше не шлётся, идёт только настоящий запрос.

Типичные значения: 3600 (час), 86400 (сутки). Браузеры ограничивают максимум: Chrome — 7200 секунд, Firefox — 24 часа, дальше всё равно скинут.

Это бэковая настройка, фронт на неё не влияет, но знать про существование полезно: если у вас в Network вы видите OPTIONS перед каждым запросом — есть смысл попросить бэкенд добавить Max-Age.

Запросы с куками: credentials: 'include'

По умолчанию fetch на другой origin не отправляет куки. Это ещё один слой защиты: даже если CORS разрешён, куки остаются дома.

Если куки нужны (например, refresh-токен в HttpOnly-куке) — явно просим:

const response = await fetch('https://api.fruntend.com/me', {
  credentials: 'include',
});

Тогда браузер пришлёт куки — но только если сервер разрешил это в preflight через два заголовка:

Access-Control-Allow-Credentials: true
Access-Control-Allow-Origin: https://fruntend.com   ← не "*", обязательно точный origin

Звёздочка вместе с Allow-Credentials: true запрещена — иначе любой сайт мог бы дёргать API с куками пользователей. Origin должен быть указан точно.

Резюме главы

  • На «сложные» запросы (DELETE, JSON-POST, кастомные заголовки) браузер сам шлёт preflight OPTIONS.
  • Сервер должен ответить нужными Access-Control-Allow-*-заголовками, иначе настоящий запрос не уйдёт.
  • Access-Control-Max-Age кэширует preflight на стороне браузера — экономит сеть.
  • Для запросов с куками нужно credentials: 'include' в fetch и Allow-Credentials: true на сервере.

В следующей главе — что делать, когда CORS всё-таки ругнулся, и как читать его сообщения.