CORS Works on Localhost, Fails in Prod — The Real Checklist
r/webdev classic: “API works in Postman, fails in browser with CORS error.” Top comment: “CORS.” OP: “But it works on localhost.” That’s exactly the point — localhost is a different origin from https://app.example.com, and your dev setup probably proxies API calls through Vite/webpack while production hits a different subdomain cold.
Access-Control-Allow-Origin: *with credentials — Browsers rejectcredentials: 'include'with wildcard origin. You must echo a specific origin.- Fixing CORS in React — No amount of axios config adds ACAO headers. Server or edge must emit them.
- “Disable CORS in Chrome” — Dev-only flag; users won’t disable it. Also doesn’t help your users.
- Assuming GET never preflights — Custom headers (
Authorization,X-Whatever) trigger preflight even on GET.
Simple vs preflighted requests
Simple (often no preflight):
- Methods: GET, HEAD, POST
- Headers: Accept, Accept-Language, Content-Language, Content-Type (limited values)
- Content-Type:
application/x-www-form-urlencoded,multipart/form-data,text/plain
Triggers preflight (OPTIONS first):
Content-Type: application/json- Custom headers like
Authorization: Bearer … - Methods: PUT, PATCH, DELETE
Both the OPTIONS response and the actual request response need correct CORS headers.
Localhost vs production comparison
| Local dev | Production | |
|---|---|---|
| Frontend origin | http://localhost:5173 | https://app.example.com |
| API | Often proxied (same origin in browser) | https://api.example.com |
| Cookies | SameSite=Lax surprises | Secure, domain-scoped |
| TLS | HTTP OK locally | HTTPS required for many APIs |
Dev proxy makes browser think API is same-origin — CORS never runs. Ship without proxy config → first real cross-origin request explodes.
Minimal server fix (Express example)
app.use((req, res, next) => {
const allowed = ["https://app.example.com", "http://localhost:5173"];
const origin = req.headers.origin;
if (allowed.includes(origin)) {
res.setHeader("Access-Control-Allow-Origin", origin);
res.setHeader("Access-Control-Allow-Credentials", "true");
}
res.setHeader("Access-Control-Allow-Methods", "GET,POST,PUT,DELETE,OPTIONS");
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
if (req.method === "OPTIONS") return res.sendStatus(204);
next();
});
Cloudflare Workers, nginx, API Gateway — same headers, different syntax.
Debug checklist (print this)
- Network tab → failed request → Response headers. Is
access-control-allow-originpresent and matching your page origin exactly (scheme + host + port)? - Preflight — Is there an OPTIONS request? Does it return 204/200 with same ACAO?
- Credentials — If using cookies, ACAO cannot be
*. NeedAllow-Credentials: trueand specific origin. - Redirect — 301 on API URL during preflight? Some clients lose CORS headers on redirect chains.
- Proxy/CDN — Cloudflare or nginx stripping headers? Check origin response vs edge response.
- Environment — Staging API allowlist missing staging frontend URL?
curl reproduction
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"
Look for Access-Control-Allow-Origin in output — if missing, browser will block regardless of Postman success.
When CORS isn’t the bug
- 401/403 masked as CORS — Sometimes no CORS headers on error responses; browser reports CORS instead of auth failure. Fix error handler to attach CORS headers on 4xx/5xx too.
- Mixed content — HTTPS page calling HTTP API — different error, but same confusion.
- DNS / SSL — Request never reaches app; still looks like “network error.”
Use json-formatter to pretty-print API JSON responses once CORS is fixed — validate payload shape separately from transport headers.
TL;DR
CORS is the browser enforcing cross-origin rules. Localhost often hides it behind a dev proxy. Production needs explicit ACAO for your real origin, correct preflight handling, and credentials rules. Fix the server, not the fetch wrapper.