The header that worked locally and would have taken production down
Adding one request header is two lines. The gateway allow-lists headers per environment, and a failed preflight kills every cross-origin call.
By Meet ModiA custom request header that works locally and fails in production almost always means the gateway's Access-Control-Allow-Headers allow-list differs per environment. The header turns a simple request into a preflighted one, and a preflight the gateway refuses fails the actual request too. Because the allow-list is global to the origin, the blast radius is every cross-origin call on the page, not just the endpoint you were changing.
Why we wanted the header at all
The backend decides which catalogue rows a caller can see based on their platform. With no explicit header it infers platform from the User-Agent, which classifies a student on Android Chrome as the native Android app.
That misclassification is not cosmetic. Those rows are then evaluated against min_version gates that a browser can never satisfy, so version-gated products, the grade filter, personalised grids and toolbar actions all silently vanish. Not an error. The page just renders less, and nobody can tell it is wrong by looking at it.
// The whole change.
headers.set("Platform", "web");What that line actually does to the request
Browsers send a small set of requests, called simple requests, straight to the server. The moment you add a header outside the safelisted set, the request stops being simple and the browser sends an OPTIONS preflight first, asking the server whether that header is permitted.
OPTIONS /api/v1/catalogue HTTP/1.1
Origin: https://www.example.com
Access-Control-Request-Method: GET
Access-Control-Request-Headers: platform
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://www.example.com
Access-Control-Allow-Headers: content-type, authorization
^ "platform" is absent, so the browser refuses to send the real request.The server never sees the GET. The browser blocks it client-side on the strength of that response, and what surfaces in the console is a CORS error that names the header.
The part that made this dangerous
Our gateway echoes Access-Control-Allow-Headers from a per-environment ops config. Dev allowed Platform, which is exactly why it worked on my machine and in review. QA and prod did not.
And the allow-list is a property of the origin, not of the route. Had this shipped, every preflighted request from the dashboard would have started failing, not just the catalogue one. The entire authenticated app, not one grid.
Probe it rather than assume it
You can ask any environment what it allows, today, without deploying anything. This went in the code comment next to the header so the next person checks instead of trusting a sentence I wrote months ago.
curl -s -i -X OPTIONS "https://api.example.com/v1/catalogue" \
-H "Origin: https://www.example.com" \
-H "Access-Control-Request-Method: GET" \
-H "Access-Control-Request-Headers: platform" \
| grep -i "access-control-allow-headers"Run it against each environment. If the header name is missing from the response, the ops config has to land there before your code does.
Two API-design decisions worth stealing
We send identity and nothing else. No app-version accompanies the platform header, because the web has no native build number and inventing one would be a claim about capability that rots as the version thresholds move. The backend instead exempts platform "web" from native version gates outright, and keeps a per-row, ops-managed hidden list as the way to hide anything with no web surface.
And only "web" is honoured from this header server-side. The ios and android values stay User-Agent-derived, so a header a client fully controls cannot be used to impersonate a native app and defeat the App Store rules about non-Apple payment methods.
The checklist
- Any non-safelisted request header triggers a preflight. Assume one.
- Probe every environment's Access-Control-Allow-Headers with OPTIONS before merging.
- Land the ops config change first, in every environment, then the client.
- Remember the allow-list is per origin: a rejected header breaks the whole app, not one call.
- Put the probe command in the code comment. The config will change again.
More Posts
Two services, one topic, either deploy order
A shared topic is only safe if the old consumer ignores what the new producer adds. One field with a default, and either side can roll back alone.
Scale on what's queued and you'll remove the pod mid-job
An autoscaler counting queued work drops to zero the moment pods claim it, so it scales into running jobs and each removal hands the work back to start over.
The model was starting and the first job didn't know
A state flag set inside the background thread instead of before launching it, so the caller that asked for the model read 'stopped' and took the slow path.