CORS Preflight Requests
Web developmentweb development / frontend
The symptom
Your POST works perfectly in Postman and curl. You ship the frontend, and the browser console screams:
Access to fetch at 'https://api.app.com/orders' from origin
'https://app.com' has been blocked by CORS policy: Response to
preflight request doesn't pass access control check.
The weird part: your server logs show NO POST hitting /orders. Instead there's a mysterious OPTIONS /orders request you never wrote. What is going on?
Reasoning through it
The browser didn't send your POST yet. Before "non-simple" cross-origin requests, it sends a PREFLIGHT — an automatic OPTIONS request that asks the server for permission first.
A request is "simple" (no preflight) only if it's GET/HEAD/POST AND uses just a basic Content-Type (text/plain, form-urlencoded, multipart). The moment you do:
fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json',
'Authorization': 'Bearer ...' },
body: JSON.stringify(order)
})
...you've tripped TWO triggers: Content-Type: application/json AND a custom Authorization header. Browser → "I must preflight this."
The OPTIONS goes out with:
Origin: https://app.com
Access-Control-Request-Method: POST
Access-Control-Request-Headers: content-type, authorization
Your server returned 200 but WITHOUT the matching approval headers, so the browser refused to send the real POST. Postman skips all this — it's not a browser and doesn't enforce CORS.
The fix
Make the server answer the OPTIONS with explicit permission:
Access-Control-Allow-Origin: https://app.com
Access-Control-Allow-Methods: POST, GET, OPTIONS
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Max-Age: 600
The allowed methods/headers must COVER what the preflight asked for, or it still fails. Max-Age lets the browser cache the approval (here 10 min) so it stops preflighting every call.
Gotchas that trip intermediate devs
- CORS is enforced by the BROWSER, not your server. The server never "blocks" anything — it just fails to grant permission, and the browser self-censors. That's why curl/Postman "work."
- Allow-Origin: * is IGNORED when you send credentials (cookies / Authorization with credentials:'include'). You must echo back the exact origin AND add Access-Control-Allow-Credentials: true.
- A 401/500 on the OPTIONS breaks everything — preflight must return 2xx even though it carries no body and runs no auth.
- Adding one custom header (X-Trace-Id) silently flips a simple request into a preflighted one. Surprise latency.
Quick exercise
You add header X-Api-Version: 2 to an existing working GET request and suddenly it's blocked. (a) Why did a plain GET start preflighting? (b) Which exact response header must the server add to fix it? (c) How could you avoid the extra round-trip entirely?