Why fetch() CORS Only Fails in Production
Localhost: fetch("http://localhost:4000/api") works. Production: console screams blocked by CORS policy: No 'Access-Control-Allow-Origin'. Someone “fixes” it by installing a browser CORS-unblock extension or by flipping a frontend flag. Those are not fixes. The browser is enforcing a rule your API (or gateway) must satisfy for the real web origin.
Why localhost lied to you
During development you often:
- Call same-origin Next/Vite proxies (
/api→ backend), so the browser never sees a cross-origin response. - Allowlist
http://localhost:3000on the API and forgethttps://app.example.com. - Run API and web on different ports but with a permissive
*ACAO that you later “hardened” in prod. - Skip
credentials: 'include'locally, then enable cookies only in production.
CORS is not a server-side security boundary by itself — it is a browser rule. curl and Postman will succeed where Chrome fails, which is why “the API works” threads go nowhere. Trust the Network tab on the real production origin, not a desktop HTTP client.
The checklist that actually finds it
1. Exact origin mismatch
Access-Control-Allow-Origin must be either * (no credentials) or the exact origin: scheme + host + port. https://www.example.com ≠ https://example.com. Trailing slashes do not belong in Origin. Preview deployments (*.vercel.app) need allowlisting or a pattern your gateway understands.
2. Preflight failures
PUT/PATCH/DELETE, custom headers (Authorization, X-Request-Id), or non-simple content types trigger OPTIONS. Your CDN or API must answer OPTIONS with something like:
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET,POST,PUT,OPTIONS
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Max-Age: 86400
If OPTIONS 404s at the load balancer, the real POST never runs. Framework middleware that only wraps successful JSON routes often forgets OPTIONS entirely.
3. Credentials
fetch(url, { credentials: "include" });
Requires Access-Control-Allow-Credentials: true and a specific ACAO — not *. Cookie SameSite and Secure flags must also match HTTPS production. A cookie that worked on HTTP localhost may never be sent on the live site.
4. CDN / edge double headers
Cloudflare, CloudFront, or an API gateway adding ACAO and the origin app adding ACAO produces duplicate headers; browsers treat that as a failure. Pick one layer to own CORS and document it.
5. Redirects during the API call
HTTP→HTTPS or www→apex on the API can drop CORS headers on the redirect response. Point the client at the final URL so the browser never follows a redirect for the XHR.
6. Error responses without CORS headers
Your happy-path middleware adds ACAO; your 401/500 path does not. The browser then reports a CORS failure instead of the real status, and you debug auth in the wrong place.
Prove it with curl (no extension required)
curl -i -X OPTIONS "https://api.example.com/v1/items" \
-H "Origin: https://app.example.com" \
-H "Access-Control-Request-Method: POST" \
-H "Access-Control-Request-Headers: content-type,authorization"
Then repeat a simple GET with Origin: https://app.example.com and inspect ACAO on the response. Compare with Origin: http://localhost:3000. The diff is your bug.
Frontend cannot “disable CORS”
Changing fetch mode to no-cors gives you an opaque response you cannot read. Browser flags only help your laptop. Ship allowlists and preflight handling on the server.
Staging matrix worth maintaining
| Client origin | API allowlist entry | Cookies? |
|---|---|---|
http://localhost:3000 | yes (dev only) | optional |
https://staging.example.com | yes | yes if used |
https://example.com | yes | yes if used |
https://www.example.com | yes if used | match apex policy |
| Vercel/Netlify preview URLs | wildcard rule or disable credentials | careful |
Automate a smoke test that fails CI when the production origin is missing from the allowlist config checked into git.
Fast path to green
- Reproduce with curl + production
Origin. - Fix allowlist and OPTIONS at the edge or API — one place only.
- Align credentials and cookie settings with HTTPS.
- Retest from an incognito window on the real domain (extensions off).
- Confirm 4xx/5xx responses still carry ACAO.
CORS that “only fails in production” almost always means the production origin was never allowlisted, preflight never reached the app, or credentials/* were combined. Fix the response headers for your real origin; stop debugging the React tree for a browser policy the API must satisfy.