You Fixed CORS and the Browser Kept Failing for Another Ten Minutes

Access-Control-Max-Age caches the preflight decision, including the one that said no. Here is why your fix looked like it did not work.

Share
You Fixed CORS and the Browser Kept Failing for Another Ten Minutes. Abstract error autopsy illustration in orange and dark grey on debugly.dev

The short answer

You corrected the CORS configuration, deployed, hard refreshed, and the browser still blocks the request. The server is now sending the right headers and you can prove it:

curl -i -X OPTIONS https://api.example.com/orders \
  -H "Origin: https://app.example.com" \
  -H "Access-Control-Request-Method: POST" \
  -H "Access-Control-Request-Headers: content-type,authorization"

The response is correct. The browser disagrees, because it is not asking. It cached the previous refusal.

# Chrome: clear only the preflight cache
# visit chrome://net-internals/#sockets and choose Flush socket pools

Or open a fresh incognito window, which has its own cache and is the fastest way to confirm the fix works.

Tested on Chrome 133, Firefox 134, nginx 1.27.

Why this happens

A preflight is the OPTIONS request a browser sends before a cross origin request that is not simple. The response can include:

Access-Control-Max-Age: 86400

That tells the browser it may reuse the preflight result for 24 hours without asking again. The specification does not say "reuse it only if it was a yes". A denial is cached exactly like an approval.

So the sequence that catches people is:

  1. Your config is wrong. Browser sends preflight, gets a response missing the required header.
  2. Browser caches that outcome for Max-Age seconds.
  3. You fix the server. You deploy. You are certain it is right.
  4. Browser does not send another preflight, because it already has an answer. It keeps blocking.

Chrome caps this at two hours regardless of what you send. Firefox caps at 24 hours. Safari has historically been shorter. So the ceiling varies, and the behaviour is the same everywhere: your fix is invisible until the entry expires.

A normal reload does not clear it. Nor does a hard reload in most cases, because the preflight cache is separate from the HTTP cache.

The causes of the original failure, ranked

1. The header list does not include what you send

Access-Control-Allow-Headers must name every non simple request header. Authorization and Content-Type: application/json both make a request non simple, and both must be listed.

Access-Control-Allow-Headers: Content-Type, Authorization, X-Request-Id

Listing * works in modern browsers, but not when credentials are involved. With Access-Control-Allow-Credentials: true, the wildcard is rejected entirely and you must enumerate.

2. Wildcard origin with credentials

This combination is invalid and browsers refuse it:

Access-Control-Allow-Origin: *
Access-Control-Allow-Credentials: true

You must echo the specific origin, which means reading the request origin, validating it against an allowlist, and reflecting it:

set $cors "";
if ($http_origin ~* ^https://(app|admin)\.example\.com$) {
  set $cors $http_origin;
}
add_header Access-Control-Allow-Origin $cors always;
add_header Access-Control-Allow-Credentials true always;
add_header Vary Origin always;

Vary: Origin matters. Without it a shared cache or CDN may serve a response containing one origin's header to a different origin, which fails in a way that looks random and depends on who requested first.

3. The OPTIONS request never reaches your application

Very common behind a proxy or a framework with authentication middleware. The preflight carries no credentials by design, so an auth layer returns 401, and a 401 without CORS headers reads to the browser as a CORS failure.

curl -i -X OPTIONS https://api.example.com/orders -H "Origin: https://app.example.com"

A 401 or 404 here means the preflight is being intercepted. It must return 204 or 200 before any authentication runs.

if ($request_method = OPTIONS) {
  add_header Access-Control-Allow-Origin $cors always;
  add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS" always;
  add_header Access-Control-Allow-Headers "Content-Type, Authorization" always;
  add_header Access-Control-Max-Age 600 always;
  return 204;
}

4. add_header silently dropped on error responses

nginx does not apply add_header to 4xx and 5xx responses unless you append always. So CORS works perfectly until your API returns a 500, at which point the browser reports a CORS error and hides the actual server error from you.

That is a particularly unhelpful failure, because the developer chases CORS while the real problem is the 500 underneath. Always use always.

5. Redirects

A preflight that receives a 301 or 302 fails. Browsers do not follow redirects on preflight. Calling http:// and being redirected to https://, or missing a trailing slash and being redirected, both produce this.

Call the final URL directly.

Reading the failure properly

Chrome's console message names the exact missing piece if you read past the first line. The Network tab is better: find the OPTIONS request, check its response headers, and compare against what the request asked for.

If there is no OPTIONS request in the list at all, that is your answer. Either the request is simple and CORS is failing on the actual request, or the preflight was served from cache.

For the general case of a request being blocked, I covered the whole header set in the CORS error autopsy. This post is specifically about why the fix appeared not to take.

Prevention

  • Set Access-Control-Max-Age to something small, 600 seconds is plenty, while you are actively developing. Raise it in production once the configuration is stable.
  • Test preflights with curl -X OPTIONS, which never uses a cache and tells you the truth immediately.
  • Always send Vary: Origin when the allowed origin is computed.
  • Use always on every nginx CORS header so error responses carry them too.
  • Handle OPTIONS before authentication, in the outermost layer that can answer it.
  • When verifying a fix, use a fresh incognito window rather than trusting a reload. It takes two seconds and removes an entire class of false conclusion.