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.
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.
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.
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.
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.
app.use(cors())
allows everything by defaultWhen 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.
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
}));
OPTIONS
requestsI 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.
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.
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.
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.