How I Finally Understood CORS, Preflight, and Custom Headers (For Real)
Adam C. |

CHECK OUT A BETTER SOLUTION: https://deniapps.com/blog/understanding-cors-preflight-and-custom-headers-in-api-requests

For the longest time, I thought I understood how CORS worked — until I started building protections for my API (api.swimstandards.com) against scrapers, and everything broke.

Turns out, I had some pretty common misconceptions about CORS, preflight, and what really happens when the browser talks to a backend. So here's what I learned — broken down simply, and hopefully useful for anyone building a secure API on a subdomain.

Photo by Cici Hung on Unsplash

🔍 What Triggered It

I wanted to block scrapers hitting my /protected-url=... API endpoint directly. So I added a custom header on real frontend clicks only, then used a Cloudflare rule to block any API calls that didn’t include this header.

It worked — but then it broke for everyone else. Why?

Because I didn't understand how CORS preflight works with custom headers and cross-origin requests.

✅ What I Learned (and You Should Too)

1. CORS is required when origins differ — even on localhost

If you're running:

Frontend at http://localhost:3000

API at http://localhost:3333

Then even though it’s the same machine, the ports are different, so the origin is different, and CORS kicks in.

2. Preflight happens when your request isn’t "simple"

Browsers send a preflight OPTIONS request if:

You set a custom header (like x-ss-header)

You use Content-Type: application/json

You use credentials: include

You send any method other than GET, POST, HEAD

And yes, even just setting Content-Type: application/json is enough to trigger preflight.

3. Preflight can silently fail — and block your real request

If the backend doesn't respond properly to the preflight, the browser will:

Never send the real request

Silently fail with a CORS error

And Cloudflare will never even see that request, because it never left the browser.

4. app.use(cors()) allows everything by default

When using the cors package in Express:

app.use(cors());

This allows:

All origins (*)

All headers

All methods

But if you want control — or to use credentials: true — you need to specify origin(s) and headers explicitly.

5. My actual working CORS config

I now use this:

const allowedOrigins = new Set([
  "https://mydomain.com",
  "https://api.mydomain.com",
  "http://localhost:3000", // local dev
]);

app.use(cors({
  origin: function (origin, callback) {
    if (!origin || allowedOrigins.has(origin)) {
      callback(null, true);
    } else {
      console.warn("❌ Blocked CORS origin:", origin);
      callback(new Error("Not allowed by CORS"));
    }
  },
  credentials: true,
  allowedHeaders: [
    "Content-Type",
    "Authorization",
    "x-my-token",  
  ],
  maxAge: 600, // ⏱️ cache preflight for 10 minutes
}));

6. Cloudflare rules must skip OPTIONS requests

I had a Cloudflare rule like this:

starts_with(http.request.full_uri, "https://api.mydomain/protected") 
and not len(http.request.headers["x-my-token"]) > 0

But that triggered on preflight OPTIONS too — where no headers are sent yet!

✅ The fix:

http.request.method ne "OPTIONS" 
and starts_with(http.request.full_uri, "https://api.mydomain/protected") 
and not len(http.request.headers["x-my-token"]) > 0

This skips preflight, and only challenges real requests missing the custom header.

7. I don’t need to whitelist browser headers

I used to think I had to add User-Agent, Accept, Sec-CH-UA, etc. to allowedHeaders.

❌ Not true.

✅ You only need to list headers you manually set via JavaScript (axios, fetch, etc.)

So in my case:

options.headers = {
  "content-type": "application/json",
  "x-my-token": generateSecretToken(),
  ...(options.headers || {}),
};

Therefore my allowedHeaders only needs to match these.

8. Preflight isn't free, but caching helps

Every preflight request is an extra round trip, which slows down your app if done often.

By setting:

maxAge: 600

the browser will cache the preflight for 10 minutes per origin/endpoint combo — which is a good balance.

🧠 Final Thoughts

Before this, I underestimated just how much cross-origin behavior is affected by headers, preflight, and server responses.

Now I’ve:

Secured my API with custom headers

Used Cloudflare rules to block scrapers

Let real browsers through — smoothly

Learned how preflight works for real

If you’ve ever had your fetch() or axios request fail with weird CORS errors — I hope this post saves you hours of debugging.