← Blog Β· May 10, 2026 Β· http, security

CORS Preflight Requests Demystified

You write a one-line fetch() call and the network tab shows two requests: an OPTIONS followed by your actual POST. The OPTIONS comes from the browser, not your code. That's the CORS preflight. Here's what it's checking.

Why CORS exists at all

The Same-Origin Policy is the browser's default: JavaScript onhttps://a.com can't read responses fromhttps://b.com. Without that rule, any page you visit could quietly send authenticated requests to your bank, your mail, your admin panel β€” using the cookies your browser already holds β€” and ship the responses anywhere.

CORS (Cross-Origin Resource Sharing) is the opt-in mechanism that lets the destination server say "it's fine, this origin can read me." The server speaks through response headers; the browser enforces.

Simple requests vs preflighted requests

Some cross-origin requests skip the preflight. The criteria are narrow:

That's an HTML-form-shaped request β€” exactly what was possible before fetch existed, so the browser doesn't need to ask permission. Anything else is preflighted.

In practice: application/json bodies, custom headers likeAuthorization, methods like PUT or DELETE β€” any of these triggers the OPTIONS first.

What the preflight asks

The browser sends:

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

Translation: "I'm at app.example.com, and I want to send a POST with these custom headers. May I?"

The server replies:

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

If the response covers everything the browser asked for, the browser proceeds with the real request. If anything is missing β€” origin not allowed, method not listed, header not in the list β€” the browser blocks the real request and your JavaScript sees a CORS error in the console.

Access-Control-Max-Age tells the browser how long to cache that positive answer. Set it to 86400 (24h) and the browser will skip preflights for a day. Most browsers cap it at 2 hours regardless.

The wildcard + credentials trap

When a request includes credentials (cookies, HTTP auth, client certs) β€” either because you passed credentials: 'include' to fetch or because you used XHR with withCredentials β€” three rules tighten up:

This is the "works without cookies, breaks with cookies" bug. The fix is to echo the request's Origin header back (with a whitelist check) instead of using *. Add Vary: Origin too, so CDNs cache per-origin.

Common mistakes

1. Allowing methods but not headers

Setting Access-Control-Allow-Methods: PUT isn't enough if the request also carries Authorization. The header has to be on the allow list too.

2. Forgetting OPTIONS in the auth middleware

A common mistake on Express/Flask/Django apps: authentication middleware rejects the preflight OPTIONS because there's no token yet. Preflights are public β€” they intentionally don't carry credentials. Exempt OPTIONS from auth.

3. Caching wrong responses

If a CDN caches a CORS response keyed on URL but not Origin, a request from one origin can poison the cache for another. Always set Vary: Originwhen the allow-origin is dynamic.

4. Putting CORS on the wrong layer

If you handle CORS at the reverse proxy (nginx) and at the application (Express), you can end up with double headers β€” some browsers reject. Pick one layer.

Related tools

← All blog posts