OneWebDesk

Fixing CORS Errors: Access-Control-Allow-Origin Explained

Why the browser throws CORS errors and how to fix each case — preflight, credentials and the wildcard trap.

Your frontend calls an API and the console lights up with blocked by CORS policy — yet the exact same request works perfectly in Postman or curl. The natural instinct is to blame the server or a firewall, but that is looking in the wrong place. CORS errors are enforced by the browser, not the server. In most cases the request actually reaches the server and gets processed; the browser receives the response, finds no header granting permission, and throws it away before your JavaScript ever sees it.

That means the fix almost never lives in your frontend code — it lives in the response headers your server sends back. This guide walks through what the same-origin policy actually is, how to read each error message, when the browser fires an OPTIONS preflight, and exactly which headers to add in each scenario — including the credentials-plus-wildcard trap that catches almost everyone at least once.

The same-origin policy: it is the browser blocking you

Browsers enforce the same-origin policy as a core security boundary. An origin is the combination of scheme://host:port — if any one of the three differs, it is a different origin. A page on https://app.example.com calling https://api.example.com is cross-origin (different host). So is http://localhost:3000 talking to http://localhost:8080 (different port).

Without this policy, any malicious page could use your logged-in browser session to silently read your bank or email APIs. So the browser blocks scripts (fetch/XHR) from reading cross-origin responses by default, and only relaxes that when the server explicitly opts in via CORS (Cross-Origin Resource Sharing) response headers. Two facts worth internalizing:

  • Postman, curl, and server-to-server calls never hit CORS errors, because the same-origin policy exists only inside browsers. “It works in curl but not in the browser” is not a bug — it is the literal definition of a CORS failure.
  • CORS is not server protection. For simple requests the server has already executed the request; the browser merely withholds the response from JavaScript. If you need access control, use authentication and authorization — not CORS.

Read the error message — it tells you which fix you need

Chrome’s console message already names the missing piece. Different wordings point to different root causes, so match the exact phrase before changing anything.

  • No 'Access-Control-Allow-Origin' header is present — the response carries no CORS header at all. Either CORS is not configured, or your error responses (4xx/5xx) skip the CORS middleware.
  • ...header has a value '...' that is not equal to the supplied origin — CORS is configured, but your origin is not on the list. Watch for www vs bare domain, http vs https, and mismatched ports.
  • Response to preflight request doesn't pass access control check — the failure happens at the OPTIONS preflight stage, not the real request. Typical causes: OPTIONS returns 404/401, or Allow-Methods/Allow-Headers are incomplete.
  • ...must not be the wildcard '*' when the request's credentials mode is 'include' — you are sending cookies while the server answers with *. That is the wildcard trap covered below.

Simple vs preflighted requests — when OPTIONS fires

The browser splits cross-origin requests into two classes. A simple request is sent immediately and only the response headers are checked. Anything outside the simple criteria triggers an automatic OPTIONS preflight first, asking the server for permission before the real request goes out. The simple criteria are narrower than most people expect:

  • Method is GET, HEAD, or POST
  • Only safelisted headers are set manually: Accept, Accept-Language, Content-Language, Content-Type
  • Content-Type is one of application/x-www-form-urlencoded, multipart/form-data, or text/plain

Which means the requests real apps make all the time — application/json POSTs, anything with an Authorization header, any PUT/DELETE/PATCH — are all preflighted. Here is the full exchange for a SPA on https://app.example.com calling an API with a bearer token.

Step 1 — the browser sends the preflight on its own:

OPTIONS /v1/orders HTTP/1.1
Host: api.example.com
Origin: https://app.example.com
Access-Control-Request-Method: POST
Access-Control-Request-Headers: authorization, content-type

Step 2 — the response the server must return (status 200 or 204, no body required):

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: Authorization, Content-Type
Access-Control-Max-Age: 86400

Step 3 — only then does the browser send the actual POST, and that response must also carry Access-Control-Allow-Origin: https://app.example.com for JavaScript to read it. Two classic traps here: the headers are required on both the preflight and the real response, and your auth middleware must not reject the OPTIONS request with 401 — preflights never carry the Authorization header. Setting Access-Control-Max-Age lets the browser cache the preflight result so OPTIONS does not fire on every single call.

Scenario-by-scenario header cheat sheet + the wildcard trap

ScenarioResponse headers the server must send
Public GET API (no cookies, no custom headers)Access-Control-Allow-Origin: * is enough
JSON POST / PUT / DELETE / custom headers (preflighted)Real response: Allow-Origin. OPTIONS response: Allow-Origin + Access-Control-Allow-Methods + Access-Control-Allow-Headers
Cookies / sessions (credentials: "include")Access-Control-Allow-Origin: <echo the request Origin> (never *) + Access-Control-Allow-Credentials: true + Vary: Origin
JS needs to read custom response headersAccess-Control-Expose-Headers: X-Request-Id, ...

The wildcard trap: the moment a request includes credentials (credentials: "include" in fetch, or withCredentials in XHR), the browser categorically rejects Access-Control-Allow-Origin: *. In credentialed mode, * in Allow-Headers and Allow-Methodsis also treated as a literal header/method named “*”, not a wildcard. The correct fix is for the server to check the incoming Origin header against an allowlist and echo that exact value back, adding Vary: Originso caches never serve one origin’s response to another. Never echo arbitrary origins unconditionally on a credentialed API — that effectively opens your cookie-authenticated endpoints to every website on the internet.

Verify the fix with real measurements

After changing server config, do not guess — inspect the actual headers on the wire. A practical order:

  1. Run the API URL and your calling origin through the CORS checker to see, from both the preflight and the actual-request perspective, which permission headers come back and which are missing.
  2. Use the HTTP headers inspector to confirm the Access-Control-* family and Vary are present on the live response. This is also where you catch CDNs or reverse proxies silently stripping headers your origin server sets.
  3. To reproduce the preflight by hand, build an -X OPTIONS request with Origin and Access-Control-Request-Method headers in the curl command builder and read the raw server response — free of browser caching, including the preflight cache.

One last tip: make sure error responses (401, 500, …) also carry CORS headers. If they do not, every real failure looks like “a CORS error” in the browser, hiding the actual cause and making debugging far harder than it needs to be.

Frequently asked questions

Why does my request work in Postman and curl but fail with CORS in the browser?
Because the same-origin policy exists only in browsers. Postman, curl, and server-to-server calls have no concept of a page origin, so no CORS check ever runs. The server is not rejecting your request — the browser is refusing to hand a response without permission headers to JavaScript. The fix is to add Access-Control-Allow-Origin and related headers to the server's responses.
Can I fix a CORS error from the frontend by changing fetch options?
No. CORS permission is granted entirely by server response headers, so nothing you add on the request side helps. mode: 'no-cors' only silences the error while making the response unreadable, so it is not a fix. If you cannot modify the server, route the call through a same-origin backend proxy instead.
When exactly does the browser send an OPTIONS preflight?
Whenever the request is not 'simple': any method other than GET/HEAD/POST (PUT, DELETE, PATCH), any custom header such as Authorization or X-Api-Key, or a Content-Type outside form-urlencoded, multipart/form-data, and text/plain — which includes application/json. The server must answer that OPTIONS with 200/204 plus Allow-Origin, Allow-Methods, and Allow-Headers before the real request is sent.
I set Access-Control-Allow-Origin: * but requests are still blocked. Why?
Your request is sending credentials (cookies or withCredentials / credentials: 'include'), and browsers forbid the wildcard in credentialed mode. The server must validate the request's Origin header against an allowlist, echo that exact origin back in Access-Control-Allow-Origin, and also send Access-Control-Allow-Credentials: true and Vary: Origin.
Preflights fire on every request and slow things down. Can I reduce them?
Yes — return Access-Control-Max-Age (e.g. 86400) on the OPTIONS response so the browser caches the preflight result; note browsers cap it (Chrome at 2 hours). Alternatively, keep requests within the simple-request rules so no preflight fires at all, though that is rarely practical for APIs needing Authorization headers or JSON bodies.

Tools to use with this guide

Related guides