Preflight requests, explained

You send one request and the Network tab shows two: an OPTIONS request first, then yours. That first one is the CORS preflight: the browser asking the server's permission before it sends anything that could change data. When it fails, your real request never goes out.

On this page
  1. What the browser sends and expects
  2. Which requests trigger one
  3. Why it fails
  4. Caching it with Access-Control-Max-Age
  5. Seeing it in DevTools

What the browser sends and expects

Say a page on https://app.example.com sends JSON with a token to https://api.example.com. Before the real request, the browser sends:

OPTIONS /orders HTTP/1.1
Origin: https://app.example.com
Access-Control-Request-Method: POST
Access-Control-Request-Headers: authorization, content-type

The server must answer with a 2xx status (204 is usual) and:

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: authorization, content-type
Access-Control-Max-Age: 7200

Only then does the browser send the real POST, which also needs Access-Control-Allow-Origin on its response.

Which requests trigger one

A cross-origin request skips the preflight only if it's "simple". Anything below makes it non-simple:

TriggerExample
Method other than GET, HEAD, POSTPUT, PATCH, DELETE
Content-Type other than the three form typesapplication/json
Any non-safelisted header (the safelist is Accept, Accept-Language, Content-Language, Content-Type with a form value, and a single-range Range)Authorization, X-Api-Key, X-Requested-With
An upload listener or a ReadableStream bodyxhr.upload.onprogress

So nearly every modern API call, JSON plus a token, gets a preflight. That's normal. Same-origin requests never get one.

Why it fails

What you seeCauseFix
OPTIONS returns 401 / 403Auth middleware ran on the preflight, which never carries credentialsHandle OPTIONS before auth
OPTIONS returns 404 / 405No route or handler for OPTIONSAdd CORS middleware that answers OPTIONS
OPTIONS returns 301 / 302Redirect (http→https, trailing slash)Call the final URL
200, but "not allowed by Access-Control-Allow-Headers"A header you send isn't listedAdd it to Allow-Headers
200, but "Method … is not allowed"Method missing from Allow-MethodsAdd it

In Express, order matters:

import cors from "cors";
app.use(cors({ origin: "https://app.example.com", maxAge: 7200 }));  // answers OPTIONS
app.use(requireAuth);                                                // runs after

The exact wording of each browser error is on "has been blocked by CORS policy": every variant.

Caching it with Access-Control-Max-Age

Without it, the browser keeps a preflight answer for only 5 seconds, so almost every call pays an extra round trip. Access-Control-Max-Age: 7200 lets the browser reuse the answer. Chrome caps it at 2 hours and Firefox at 24 hours; a larger value is simply cut to the cap.

Seeing it in DevTools

In the Network tab, Chrome lists the preflight as a separate row with type preflight; clear any filter to see it. Click it to read what the server answered. To check a URL from outside your app, the CORS tester sends the request and shows which header is missing.

HeaderForge (Chrome, free)

When a request on the current tab is blocked by CORS, HeaderForge names the server and offers to allow CORS for that site in your browser. It says so plainly when the failure is a rejected preflight, because no header change can fix a 401 or 404 status; that one needs the server fix above.

Get HeaderForge for Chrome

Related: what CORS is · CORS error guide · disable CORS in Chrome.

More on this topic: How to disable CORS in Firefox (and why content.cors.disable makes it worse) · TypeError: Failed to fetch — what it actually means, and how to tell which cause · net::ERR_BLOCKED_BY_CLIENT, ERR_BLOCKED_BY_RESPONSE and ERR_BLOCKED_BY_ORB · Access-Control-Allow-Origin: values, multiple origins and server examples