Your idempotency key triggers a CORS preflight that bills the user twice
The custom header I added to stop double-charges was the exact thing CORS silently stripped, so the safety mechanism became the bug it was supposed to prevent.
I shipped the textbook fix for double-charging: a client-generated idempotency key, sent as a custom header, replayed on the server so a retry lands on the same row instead of a new charge. Then a user on a bad connection tapped “place order” twice and got billed twice anyway. The header that was supposed to stop it never arrived, because the browser’s CORS preflight quietly threw it away before my server ever saw it.
That’s the whole trap in one sentence: the mechanism protecting the order endpoint was, on the production domain, the precise thing breaking it.
Why the safe version was the broken one
The setup is exactly the one everyone recommends. On a flaky network a user taps the button, the request times out on their phone, but it actually reached the server and succeeded. They tap again. Without a key, that’s a second order and a second charge. With a key, the second request carries the same Idempotency-Key, the server recognizes it, and replays the original result. Clean.
The part nobody puts in the same paragraph: the moment you add a non-standard request header, you’ve changed the request’s nature in the browser’s eyes.
- A simple request goes straight through. Plain
Content-Type, no custom headers, and the browser just sends it. No permission slip required. - A custom header makes it “non-simple.” Add
Idempotency-Keyand the browser now insists on asking the server for permission first, with a preflightOPTIONScall, before it will send the real request. - The preflight gates the header by name. The server has to answer that
OPTIONSwithAccess-Control-Allow-Headers: Idempotency-Key. If that header name isn’t in the allow-list, the browser refuses to attach it. It doesn’t error loudly. It drops your key and sends the request anyway, naked.
So in development, where everything is same-origin and there is no preflight, the key sailed through and every test passed. In production, across origins, the preflight ran, my CORS config didn’t name the header, and the browser stripped the one field the entire safety net depended on. Two taps, two keyless requests, two charges. The code was correct. The conversation between the browser and the server was not.
The part that made it hard to see
What made this genuinely nasty is that nothing was broken in any file I could open. The client correctly generated the key and set the header. The server correctly checked for the key and would have deduplicated on it. The idempotency logic, read in isolation, was flawless. The failure lived entirely in a layer most of us treat as boilerplate we copy once and stop reading: the CORS config.
A header your CORS policy doesn’t name is a header your client doesn’t send. The safest-looking request and the broken one are the same request.
You can review the client in one pull request and the server in another, approve both honestly, and ship a regression, because the bug is in the seam where the browser silently enforces a contract neither file mentions. Preflight failures are deliberately quiet by design. The whole point of CORS is that the browser, not your code, decides what crosses an origin, and it does it without asking you.
How I close it now
The fix was one line: add Idempotency-Key to Access-Control-Allow-Headers. Trivial, once I knew. But the line was never the lesson.
The lesson is that idempotency is not just the dedup logic, it’s the whole path the key travels, and any one hop that drops it makes the rest theater. So now, anything idempotent only counts as done when I’ve watched the key survive across origins, on the real production domain, in the actual browser, not on localhost where preflight never fires. I open the network tab, find the OPTIONS call, and confirm the request that follows it still carries the header.
If you add a custom header to protect a money endpoint, go read your CORS config in the same sitting. The mechanism is half the work. The wiring that lets it reach the server is the other half, and it fails silent.