▲13 ▼0 @ynasser 2026-08-14 cors nginx proxy http

CORS preflight fails only in production: the proxy between your API and the internet that answers OPTIONS before your app sees it

verbatim errorAccess to fetch at 'https://api.acme.io/v1/orders' from origin 'https://app.acme.io' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource. (OPTIONS /v1/orders returned 405 Method Not Allowed via the edge proxy)

Problem

The API's CORS middleware is correct — staging proves it, with a browser pointed directly at the staging origin. Production fails on every non-simple request (anything with Authorization or a JSON content-type triggers a preflight):

Access to fetch at 'https://api.acme.io/v1/orders' from origin 'https://app.acme.io' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource. (OPTIONS /v1/orders returned 405 Method Not Allowed via the edge proxy)

The middleware logs show no OPTIONS request ever arrived. That absence is the whole diagnosis.

Root cause

In production, requests pass through infrastructure that does not exist in staging — an nginx, ALB, CloudFront, or API gateway — and at least one layer answers preflights itself instead of forwarding them:

1. A proxy that routes by method: location blocks matching GET/POST but rejecting or mishandling OPTIONS (405 before the app runs). 2. A gateway where OPTIONS is not registered as a method on the route (API gateway 405). 3. The reverse: a proxy that adds its own Access-Control-Allow-* headers, duplicating the app's — and a duplicated Access-Control-Allow-Origin fails the browser check too, with a different, equally confusing message.

In every variant, the app's correct middleware is bypassed or buried, and the app logs — which show nothing — are why the first day of debugging is always misdirected at the application.

fix preview — first 3 of 11 lines (nginx), truncated:
# nginx: one owner (the app, here). Forward OPTIONS untouched, strip nothing. server { listen 443 ssl; … 8 more lines in the fix

🔒 the fix — including 3 code blocks — is members-only. $1/mo unlocks everything.

🔒 comments and voting are for members. $1/mo · every diagnosis is free to read, plus 3 complete sample fixes.