Stop fighting CORS
A CORS error is a server configuration problem wearing a browser error message. What the preflight actually asks, what breaks when credentials are involved, and the fixes that are not a proxy.
In one sentence
A CORS error is the browser reporting that a server did not opt in to being called from your origin — nothing you write in the frontend can fix it.
Why it matters
CORS is the single most misdiagnosed error in frontend work, because the message
appears in your console, points at your fetch call, and says nothing about the
server that actually made the decision. That leads people to try things that
cannot possibly work: adding Access-Control-Allow-Origin to the request,
setting mode: 'no-cors', or disabling web security in their browser and
declaring it fixed.
The rule underneath is the same-origin policy: a page may send a cross-origin
request, but it may not read the response unless the response says it may.
Origin means scheme, host, and port — https://app.example.com and
https://api.example.com are different origins, and so are http:// and
https:// on the same host.
What the preflight actually asks
Some requests go straight out. Others are preceded by an OPTIONS request that
asks permission first.
A request is simple — no preflight — only if all of these hold:
- The method is
GET,HEAD, orPOST - The only headers you set are on the CORS-safelisted list
Content-Typeisapplication/x-www-form-urlencoded,multipart/form-data, ortext/plain
That last one is why almost every JSON API call is preflighted:
Content-Type: application/json is not on the list.
The exchange looks like this:
OPTIONS /v1/orders HTTP/1.1
Origin: https://app.example.com
Access-Control-Request-Method: POST
Access-Control-Request-Headers: content-type, authorization
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: POST, GET, OPTIONS
Access-Control-Allow-Headers: content-type, authorization
Access-Control-Max-Age: 86400
Three things worth noting:
Access-Control-Allow-Headersmust list every header you send. Add aX-Request-Idfor tracing and the preflight starts failing.Access-Control-Max-Agecaches the preflight. Without it you pay a second round trip on every request. With86400, once a day.- The preflight must not require auth. A server that returns
401forOPTIONSfails CORS before the real request is ever sent.
Credentials change every rule
The moment you send cookies or use credentials: 'include', the permissive
shortcuts stop working:
| Header | Without credentials | With credentials |
|---|---|---|
Access-Control-Allow-Origin | * is fine | Must be the exact origin |
Access-Control-Allow-Headers | * is fine | Must list each header |
Access-Control-Allow-Credentials | Not needed | Must be true |
So a server that echoes the request's Origin header — rather than hardcoding
* — is the only configuration that works for an authenticated API. Echoing is
also where the security bug lives: echo without validating against an allowlist
and any site on the internet can read your authenticated responses.
// Server side. Echo, but only from a list you control.
const ALLOWED = new Set(['https://app.example.com', 'https://staging.example.com'])
const origin = request.headers.get('origin')
if (origin && ALLOWED.has(origin)) {
headers.set('Access-Control-Allow-Origin', origin)
headers.set('Access-Control-Allow-Credentials', 'true')
headers.set('Vary', 'Origin') // or your CDN will cache one origin's answer for all
}
That Vary: Origin line is not optional. Without it, a shared cache will serve
the Allow-Origin value it got for the first requester to everyone else, and you
get CORS errors that only reproduce for some users.
Reading the error
The console message tells you which fix applies, if you read past the first line:
| Message fragment | What is missing |
|---|---|
| "No 'Access-Control-Allow-Origin' header" | The server sent nothing — it may not know about CORS |
| "does not match the supplied origin" | Hardcoded to a different origin, often production |
| "Method PATCH is not allowed" | Access-Control-Allow-Methods |
| "Request header field x-… is not allowed" | Access-Control-Allow-Headers |
| "Response to preflight … status 401" | Auth middleware is running before the OPTIONS handler |
"credentials mode is 'include'" with * | Wildcard origin plus credentials |
Also check the Network tab, not just the console. If you see no OPTIONS
request at all, the failure is earlier than CORS — DNS, TLS, or a connection
refused.
The fixes that are not a proxy
A dev proxy (server.proxy in Vite, rewrites in Next.js) makes the request
same-origin so the browser stops asking. That is legitimate for local
development, and it is a trap for production, because it hides a configuration
gap until launch day.
In rough order of preference:
- Fix the server. If you own the API, this is a four-line change.
- Serve the API from the same origin behind a path prefix on your CDN.
example.com/api/*routed to the backend has no CORS at all. - Proxy through your own backend when the third party will not add your origin. You are now the client, and server-to-server calls have no CORS.
mode: 'no-cors'is not a fix. It gives you an opaque response you cannot read, withstatus0 and no body. It exists for<img>-style fire-and-forget.
Common pitfalls
- Assuming a
404or500behind a CORS error is a CORS problem. The browser reports the CORS failure first, but the server may simply be broken — check it withcurl, which has no same-origin policy. - Forgetting that a redirect re-runs the CORS check against the new origin.
- Adding CORS headers to an error response path but not the success path, or vice versa.
- Treating
Access-Control-Allow-Origin: *as a security control. It is the opposite: it makes the resource public.
Further reading
- Cross-Origin Resource Sharing (CORS) — MDN's reference, including the full safelist.
- Same-origin policy — the rule CORS is an exception to.