Debugging 2026-05-18

CORS Errors That Only Happen in Production

The first time I had to debug a CORS error in production that didn’t exist anywhere else, it took most of an afternoon, because I kept trying to reproduce it locally — and locally, everything was fine. It was fine because localhost was lying to me: my dev server had a permissive CORS middleware that production’s nginx config didn’t share, my browser had a cached preflight response, and the CDN in front of production was quietly eating the Access-Control-Allow-Origin header on cached responses. Three separate lies.

Production-only CORS failures are almost never mysterious once you accept the premise: the environments are actually different, and the difference lives in one of four places — the origin, the preflight cache, the credentials mode, or a middlebox between browser and app. Let’s take them in the order you should check them.

Rule zero: get the error text, not the vibe

“CORS error” in a bug report is nearly information-free. The browser console message tells you which check failed, and each failure has a different fix:

If you can’t get the console text from the user — production-only bugs are often someone else’s browser only — this is precisely the situation where frontend error capture or session replay with network telemetry pays for itself. Failing that, you’re reproducing blind.

How to debug a CORS error in production: start with curl

The fastest way to debug a CORS error in production is to stop using a browser. curl shows you exactly what the server sends, with no cache and no security theater:

# Simulate the preflight
curl -si -X OPTIONS https://api.example.com/v1/orders \
  -H "Origin: https://app.example.com" \
  -H "Access-Control-Request-Method: PUT" \
  -H "Access-Control-Request-Headers: content-type,authorization" \
  | grep -i '^access-control\|^HTTP'

# Simulate the actual request
curl -si https://api.example.com/v1/orders \
  -H "Origin: https://app.example.com" \
  | grep -i '^access-control\|^vary\|^HTTP'

Run both. You need Access-Control-Allow-Origin on both responses — a passing preflight does not exempt the real response from carrying the header. That asymmetry (OPTIONS handled by a gateway that adds headers, GET handled by an app that doesn’t, or vice versa) is a classic.

Now the four production-only causes.

Cause 1: preflight caching hides the fix — and the break

Preflight responses are cached per-URL according to Access-Control-Max-Age. Chrome caps this at 2 hours; Firefox at 24 hours; the spec default if you send nothing is 5 seconds. Two nasty consequences:

  1. You deploy broken CORS config, some users cache the working preflight from before the deploy, and the bug appears to affect a random subset of traffic for hours.
  2. You deploy the fix, and users who cached the broken preflight keep failing. You conclude the fix didn’t work and revert it. I have personally ridden this carousel.

So: after any CORS change, judge success by curl, not by refreshing a browser that has state. And set Access-Control-Max-Age deliberately — 600 seconds is a reasonable value that bounds how long a bad deploy haunts you.

Cause 2: credentials mode meets wildcard origin

The CORS spec flatly forbids Access-Control-Allow-Origin: * when the request carries credentials (cookies, Authorization header via credentials: 'include'). Dev setups dodge this constantly: locally you’re on the same origin so CORS never fires, or your dev API returns the literal origin. Production has the wildcard because someone set cors: '*' in a config file two years ago, and it worked fine until the frontend team added credentials: 'include' for the new auth flow.

The fix is to echo the specific origin — validated against an allowlist, never blindly reflected — and add Access-Control-Allow-Credentials: true plus Vary: Origin. That Vary header matters more than it looks, which brings us to:

Cause 3: the CDN or proxy is eating your headers

Anything between the browser and your app can drop or mangle CORS headers, and these boxes usually don’t exist in development.

The CDN version: your API responses are cached, the cache key ignores the Origin request header because you didn’t send Vary: Origin, and the CDN serves a response cached for origin A to a request from origin B — or serves a response cached from a request that had no Origin header, and therefore no CORS headers at all. Intermittent, traffic-dependent, maddening.

The proxy version: an nginx location block that handles errors (error_page, auth subrequests, rate-limit 429s) returns responses that never touched your app’s CORS middleware. So the happy path has headers and every error doesn’t — which the frontend experiences as “the API turns into CORS errors whenever it’s down.” The browser can’t read the status code of a response that fails the CORS check, so your error handling gets nothing. If you terminate CORS in nginx, use add_header ... always so the headers apply to non-2xx responses too (per the nginx headers module docs, add_header without always only fires on a limited set of status codes).

Related trap in the same middlebox family: a deploy invalidates hashed asset filenames while HTML is still cached, which produces a different flavor of production-only frontend failure — chunk load errors right after a deploy — that often gets misreported as CORS because both surface as opaque network failures.

Cause 4: plain environment drift

The boring one, and still worth checking early: the allowlist in production simply doesn’t contain the origin. Common variants — the config has https://app.example.com and the user is on https://www.app.example.com; the allowlist was updated in staging’s environment file and not production’s; a trailing slash in the config (origins never have one); the port is in the config but production runs on 443 so the browser sends the origin without it.

Origins are compared as exact strings: scheme, host, port. There is no “close enough.”

The decision path, compressed

Observation (from curl) Likely cause Fix location
No access-control-* headers at all Proxy/CDN stripping, or middleware not mounted on that route nginx/CDN config
Headers on GET, OPTIONS fails Whatever answers OPTIONS (gateway, proxy) differs from the app Route OPTIONS explicitly
Headers present, wrong origin echoed Allowlist drift or cache mixing origins Config + Vary: Origin
* plus a credentialed request Wildcard/credentials conflict Echo validated origin
curl is perfect, one browser fails Preflight cache holding stale state Wait out Max-Age, or test in a fresh profile
Works until the API errors Headers missing on non-2xx responses add_header ... always, or CORS in the app layer

One habit change makes most of this table unnecessary: treat CORS as production config, not code. Put the allowlist per environment in one reviewed place, send Vary: Origin on everything, verify with the two curl commands as a post-deploy check. It’s a five-line smoke test, and it would have saved me that afternoon.

See the bug the way your user did

LogReplay captures session replays, console output, network requests, and errors in one timeline — so you stop guessing what happened before the ticket arrived.

Try LogReplay free