When does a CORS preflight happen? The conditions that send OPTIONS first

When fetch() sends OPTIONS first, per the WHATWG Fetch Standard: safelisted methods and headers, why application/json and Authorization trigger a preflight, and the 5-second Max-Age default.

DevelopmentWebHTTP

Send a cross-origin JSON POST with Content-Type: application/json using fetch(), and, when no matching CORS-preflight cache entry exists, the network panel shows an OPTIONS request to the same URL. That is a CORS preflight. Before sending the actual request, the browser checks whether the server understands the CORS protocol.

This post is based on the WHATWG Fetch Standard (version last updated 21 September 2026) and RFC 9110. Fetch is a Living Standard that keeps changing, so section numbers and individual steps may move. What follows is the behaviour the standard specifies. Individual browser implementations were not checked.

When a preflight is added

Same-origin requests do not go through CORS at all. Cross-origin requests made with fetch() and XMLHttpRequest have the standard’s internal unsafe-request flag set. Such a request requires preflight permission if any of the following holds. However, the browser can omit a new OPTIONS request when a matching CORS-preflight cache entry exists.

  • The method is not GET, HEAD or POST.
  • It has a request header that is not CORS-safelisted.
  • It uses a ReadableStream as the body, or event listeners are registered on XMLHttpRequestUpload.

The safelisted request headers are Accept, Accept-Language, Content-Language and Content-Type, plus Range only for a single range such as bytes=0-499. A value longer than 128 bytes drops out of the list. For Content-Type, the MIME type must be application/x-www-form-urlencoded, multipart/form-data or text/plain. If the values of these headers add up to more than 1024 bytes, all of them are treated as unsafe. The Fetch Standard has no term “simple request”; it has only these conditions.

Request Preflight required Reason
fetch(url) No GET with no extra headers
fetch(url, { method: "POST", body: JSON.stringify(data) }) No A string body gets Content-Type: text/plain;charset=UTF-8
The same with "Content-Type": "application/json" Yes application/json is not safelisted
headers: { Authorization: "Bearer ..." } Yes Authorization is not safelisted
method: "DELETE" Yes Not a safelisted method

In the second row, if you don’t set Content-Type yourself, Request fills in a value that matches the body type. The server therefore receives the JSON string as text/plain.

No preflight does not mean no CORS check. The request goes straight to the server, and if the response fails the CORS check, the browser hands the script a network error. By then the server may already have processed the request.

The request and a response that passes

A JSON POST with Authorization produces this preflight (main headers only):

OPTIONS /api/items HTTP/1.1
Origin: https://app.example
Accept: */*
Access-Control-Request-Method: POST
Access-Control-Request-Headers: authorization,content-type

Access-Control-Request-Headers lists the non-safelisted header names in lowercase, sorted and joined by commas with no spaces.

HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.example
Access-Control-Allow-Methods: POST
Access-Control-Allow-Headers: authorization, content-type
Access-Control-Max-Age: 600

To pass, the status must be 200–299 and Access-Control-Allow-Origin must equal the request’s origin or be *. When cookies and the like are sent with credentials mode "include", * does not work and Access-Control-Allow-Credentials: true is also required. In that mode, * for methods and headers is not treated as a wildcard either. Authorization must be named explicitly in Access-Control-Allow-Headers whatever the mode. If the check fails, the real request is not sent.

A preflight request carries no credentials such as cookies or Authorization. If authentication middleware also demands a token on OPTIONS and returns 401, the preflight fails on the status requirement.

Caching the result

Access-Control-Max-Age sets how many seconds the allowed methods and headers may be cached. Without the header it is 5 seconds. Browsers may impose an upper limit; the standard does not give a value, and this post did not check one. Each cache entry records a network partition key, serialized origin, URL, max-age, a credentials flag, and either a method or header name. Entries must be removed once max-age has passed, and may be removed earlier.

RFC 9110 says responses to OPTIONS are not cacheable. A preflight result lives not in the HTTP cache but in the separate CORS-preflight cache that the Fetch Standard defines.

What a 401 response itself means is covered in 401 vs 403.

Specifications consulted

  • WHATWG Fetch Standard (Living Standard, last updated 21 September 2026): 2.2.1 Methods, 2.2.2 Headers, 2.2.3 Statuses, 2.2.5 Requests, 3.3.2–3.3.5 CORS protocol, 4.1 Main fetch, 4.4 HTTP fetch, 4.8 CORS-preflight fetch, 4.9 CORS-preflight cache, 4.10 CORS check, 5.2 BodyInit unions, 5.4 Request class
  • RFC 9110 — HTTP Semantics: 9.3.7 OPTIONS

Links and content checked on 5 October 2026.

Back to the blog