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.
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.
A cross-origin request skips the preflight only if it's "simple". Anything below makes it non-simple:
| Trigger | Example |
|---|---|
| Method other than GET, HEAD, POST | PUT, PATCH, DELETE |
| Content-Type other than the three form types | application/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 body | xhr.upload.onprogress |
So nearly every modern API call, JSON plus a token, gets a preflight. That's normal. Same-origin requests never get one.
| What you see | Cause | Fix |
|---|---|---|
| OPTIONS returns 401 / 403 | Auth middleware ran on the preflight, which never carries credentials | Handle OPTIONS before auth |
| OPTIONS returns 404 / 405 | No route or handler for OPTIONS | Add CORS middleware that answers OPTIONS |
| OPTIONS returns 301 / 302 | Redirect (http→https, trailing slash) | Call the final URL |
| 200, but "not allowed by Access-Control-Allow-Headers" | A header you send isn't listed | Add it to Allow-Headers |
| 200, but "Method … is not allowed" | Method missing from Allow-Methods | Add 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.
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.
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.
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 ChromeRelated: 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