If your frontend talks to your API fine on localhost and dies with a CORS error in production, the cause is almost never "CORS is broken." It is that locally the browser saw one origin (a dev-server proxy) and in production it sees two, and the first cross-origin request your app makes is now a preflight OPTIONS that your backend answers wrong — often with a 401, a redirect, or a 500 that carries no CORS headers at all. Fix it in this order: read the actual preflight response, decide where CORS belongs (proxy, app, or gateway), then make sure your CDN is not caching the answer for the wrong origin.
Why does it work locally and fail in production?
In development, Vite, Next.js, and Create React App all proxy /api to your backend. The browser sees http://localhost:5173/api/items — same origin, no CORS at all. In production you deploy the frontend to https://app.example.com and the API to https://api.example.com, and every request is suddenly cross-origin.
Cross-origin does not automatically mean preflight. The browser sends a preflight only when the request is not "simple": a method other than GET, HEAD, or POST, a Content-Type outside application/x-www-form-urlencoded, multipart/form-data, and text/plain, or any header you set yourself. That is why the failure often looks arbitrary: your GET /health works, and POST /v1/items with Content-Type: application/json and an Authorization header fails. The JSON content type alone is enough to trigger the preflight.
The first thing to establish is not "is CORS configured" but "did this request preflight at all" — those are two different bugs with two different fixes.
How do I read the real error instead of guessing?
Chrome's console messages are specific, and each one points at a different header:
| Console message (abridged) | What it actually means |
|---|---|
No 'Access-Control-Allow-Origin' header is present |
The response never went through your CORS layer — wrong route, error path, or middleware order |
Response to preflight request doesn't pass access control check |
The OPTIONS response is the problem, not your POST
|
Request header field authorization is not allowed by Access-Control-Allow-Headers |
Preflight answered, but the allow-list is missing a header you send |
Method PATCH is not allowed by Access-Control-Allow-Methods |
Same, for the method |
...must not be the wildcard '*' when the request's credentials mode is 'include' |
You are sending cookies; * is illegal in that mode |
Redirect is not allowed for a preflight request |
Your OPTIONS got a 301/302 (usually HTTP→HTTPS or a trailing-slash rule) |
Then reproduce the preflight by hand. This is the single most useful command in the whole investigation, because it shows you exactly what the browser sees without the browser in the way:
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'
You want 204 (or 200) plus Access-Control-Allow-Origin, Access-Control-Allow-Methods, and Access-Control-Allow-Headers covering what you asked for. What I usually find instead is a 401: the preflight carries no cookies and no Authorization header by design, so any auth middleware mounted before the CORS layer rejects it. The browser then reports a generic CORS failure and never sends the real request.
A curl -i -X OPTIONS that returns 401 or 302 is your answer — stop reading CORS docs and fix middleware order or redirect rules.
Where should CORS live: proxy, app, or gateway?
This is the decision that actually matters, and most teams make it by accident.
| Approach | Good when | Real drawback |
|---|---|---|
Same-origin path routing (/api/* → backend at the edge) |
You control DNS and want CORS to disappear entirely | Another hop to operate; cookie Domain/Path and cache rules need review |
| CORS in the app (framework middleware) | Small number of services, origins known at deploy time | Every service re-implements it; error responses easily bypass it |
| CORS at the gateway | Many services behind one entry point | Two places can emit the headers, and duplicated Access-Control-Allow-Origin is itself a hard failure |
Same-origin routing is underrated. If your frontend already sits behind a CDN, forwarding app.example.com/api/* to the API removes preflights, third-party cookie problems, and the entire Allow-Headers allow-list in one move. If you want that without running a reverse proxy yourself, Cloudflare Workers can sit in front of the existing hostname and forward /api/* to the backend, which keeps the browser on one origin. The cost is real: you now own a routing layer, and you have to be deliberate about what it caches.
If you keep CORS in the app, mount it first and make sure it also wraps error handlers. In Express:
const express = require('express')
const cors = require('cors')
const app = express()
const allowed = new Set(['https://app.example.com', 'https://staging.example.com'])
app.use(cors({
origin: (origin, cb) => {
// no Origin header: curl, server-to-server, same-origin
if (!origin || allowed.has(origin)) return cb(null, true)
return cb(new Error(`origin not allowed: ${origin}`))
},
credentials: true,
methods: ['GET', 'POST', 'PATCH', 'DELETE'],
allowedHeaders: ['Content-Type', 'Authorization'],
maxAge: 600,
}))
app.use(express.json())
app.post('/v1/items', (req, res) => res.status(201).json({ ok: true }))
app.listen(3000)
Two notes from getting this wrong. The cors middleware already answers OPTIONS when mounted with app.use, so a separate catch-all OPTIONS route is unnecessary — and on Express 5 a bare app.options('*', ...) no longer behaves as it did on 4, because the path-matching rules changed. Second, if auth middleware comes before this block, you are back to the 401 preflight.
In FastAPI the equivalent is CORSMiddleware, and the trap is different: passing allow_origins=["*"] together with allow_credentials=True is invalid per spec, and frameworks resolve that contradiction inconsistently rather than erroring.
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
app = FastAPI()
app.add_middleware(
CORSMiddleware,
allow_origins=["https://app.example.com"],
allow_credentials=True,
allow_methods=["GET", "POST", "PATCH", "DELETE"],
allow_headers=["Content-Type", "Authorization"],
max_age=600,
)
Never trust the config object — trust the curl -i output, because the header your framework actually emits is the only thing the browser evaluates.
What changes the moment cookies are involved?
Once the frontend sends credentials: 'include', three rules bind at once: Access-Control-Allow-Origin must be an exact origin (no *), Access-Control-Allow-Credentials: true must be present, and wildcards in Allow-Headers or Allow-Methods stop counting. A cookie crossing sites also needs SameSite=None; Secure — so the "CORS error" may be a cookie that was never sent.
The fastest way to tell them apart: if the preflight passes and the real request returns 401, CORS is fine and the cookie is the problem. Check the Network tab's request headers for Cookie before touching any CORS config.
Why does the fix work for one user and not another?
Because something cached it. If you echo the request's Origin into Access-Control-Allow-Origin — which you must do for credentialed requests — the response varies by a request header, and any shared cache in front of you needs to know that:
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Credentials: true
Vary: Origin
Without Vary: Origin, a CDN can serve app.example.com's allow-header to staging.example.com and back, and the symptom is the worst kind: intermittent, user-specific, unreproducible on your machine. I have also watched a correct fix look like a non-fix because a stale preflight was still cached — browsers cache preflight results per Access-Control-Max-Age, and Chromium caps that at two hours (as of mid-2026). Test fixes in a fresh incognito window, or keep maxAge low until things are stable.
Any response whose CORS headers depend on the request's Origin must send Vary: Origin, or your cache will eventually hand the wrong answer to the wrong site.
FAQ
Why does my API work in Postman but not in the browser?
Postman and curl are not browsers and do not enforce CORS — they never send a preflight and never check response headers. A request succeeding in Postman tells you the endpoint works; it tells you nothing about CORS.
Can I fix a CORS error from the frontend?
No. CORS headers come from the server that owns the resource, so the only frontend-side "fixes" are avoiding the cross-origin request entirely (proxy the call through your own origin) or, for third-party APIs you do not control, calling them from your backend.
Why does my preflight return 401?
Preflight OPTIONS requests deliberately carry no cookies and no Authorization header, so authentication middleware rejects them unless CORS handling runs first. Mount your CORS layer before auth, or exempt OPTIONS from authentication.
Bottom line
Debug in this order: confirm whether a preflight is involved, reproduce it with curl -i -X OPTIONS, and only then edit configuration. If you control both hostnames, same-origin path routing at the edge is the most durable fix, because it deletes the problem class instead of configuring it. If you keep CORS in the app, mount it before auth, list your origins explicitly, and send Vary: Origin. And if the preflight passes but the real request 401s, you have a cookie problem wearing a CORS costume.
Top comments (0)