HAMZA KAREEM / CONCEPTS hamzawi.dev

Lesson 121 min read

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?