Why fetch() CORS Only Fails in Production

CORS fetch 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:3000 on the API and forget https://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.comhttps://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 originAPI allowlist entryCookies?
http://localhost:3000yes (dev only)optional
https://staging.example.comyesyes if used
https://example.comyesyes if used
https://www.example.comyes if usedmatch apex policy
Vercel/Netlify preview URLswildcard rule or disable credentialscareful

Automate a smoke test that fails CI when the production origin is missing from the allowlist config checked into git.

Fast path to green

  1. Reproduce with curl + production Origin.
  2. Fix allowlist and OPTIONS at the edge or API — one place only.
  3. Align credentials and cookie settings with HTTPS.
  4. Retest from an incognito window on the real domain (extensions off).
  5. 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.