Frontend Inbox

Stop fighting CORS

Intermediate8 minUpdated 2026-09-01

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.

#security
#http

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, or POST
  • The only headers you set are on the CORS-safelisted list
  • Content-Type is application/x-www-form-urlencoded, multipart/form-data, or text/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:

  1. Access-Control-Allow-Headers must list every header you send. Add a X-Request-Id for tracing and the preflight starts failing.
  2. Access-Control-Max-Age caches the preflight. Without it you pay a second round trip on every request. With 86400, once a day.
  3. The preflight must not require auth. A server that returns 401 for OPTIONS fails 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:

HeaderWithout credentialsWith credentials
Access-Control-Allow-Origin* is fineMust be the exact origin
Access-Control-Allow-Headers* is fineMust list each header
Access-Control-Allow-CredentialsNot neededMust 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 fragmentWhat 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:

  1. Fix the server. If you own the API, this is a four-line change.
  2. 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.
  3. 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.
  4. mode: 'no-cors' is not a fix. It gives you an opaque response you cannot read, with status 0 and no body. It exists for <img>-style fire-and-forget.

Common pitfalls

  • Assuming a 404 or 500 behind a CORS error is a CORS problem. The browser reports the CORS failure first, but the server may simply be broken — check it with curl, 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