CORS Explained Without Confusion
A developer opens the console, sees a red "blocked by CORS policy" error on a fetch call, and assumes the request never reached the server. It often already did — the like counter it was supposed to increment shows one higher than before, even though the browser never let the page read the response. CORS is enforced by the browser, on the response, and for a wide category of requests that check happens only after the server has already done the work.
This guide separates the same-origin policy the browser enforces by default from the CORS headers a server uses to opt back into cross-origin reads, follows one dashboard widget calling a different API origin through both a simple request and a preflighted one, and traces a real failure where a blocked response didn't mean a blocked side effect. For the authentication headers involved when credentials cross an origin, see REST API Authentication Methods.
The Same-Origin Policy: The Browser's Default
An origin is the exact combination of scheme, host, and port — https://dashboard.example.com and https://api.example.com are different origins even though they share a parent domain; so are http://example.com and https://example.com, and example.com:3000 and example.com:8080. The browser's same-origin policy (SOP) says JavaScript running on one origin cannot read the response of a request to a different origin, by default.
The reason is the browser automatically attaches cookies to every request to a site, including ones triggered by a different page's JavaScript. Without SOP, a malicious page could fetch('https://bank.example.com/balance') in the background, ride the victim's existing session cookie, and read the balance in the response — an attack the ambient nature of cookies makes possible unless something blocks the read. SOP blocks it by default; CORS is the mechanism a server uses to explicitly open a hole in that default for specific origins.
Quick reference
- Origin = scheme + host + port, compared exactly — a subdomain or different port is a different origin.
- SOP blocks JavaScript from reading a cross-origin response, not from the browser making the request.
- The attack SOP prevents: a malicious page riding the victim's ambient cookies to read another site's authenticated data.
- CORS headers are how a server says 'this specific other origin may read my response' — the default without them is still SOP's block.
- Server-to-server calls (no browser involved) are never subject to CORS — this is entirely a browser-JavaScript mechanism.
Remember this
The same-origin policy blocks JavaScript from reading a cross-origin response by default because cookies ride along automatically — CORS headers are the server's explicit exception to that block.
Simple Requests: The Server Already Ran Before CORS Blocks Anything
A simple request — method GET, HEAD, or POST; only safelisted headers (Accept, Accept-Language, Content-Language); and a Content-Type of application/x-www-form-urlencoded, multipart/form-data, or text/plain — goes straight over the wire with no preliminary check. The server receives it, runs its handler, and returns a response exactly as it would for a same-origin call. Only after the response arrives does the browser check whether Access-Control-Allow-Origin permits the calling page's origin to read it. If not, the response exists, the server-side work happened, and the page's JavaScript simply never sees the result.
This is the gap that catches teams off guard: a cross-origin POST with a form-urlencoded body that increments a counter, sends an email, or writes a row is a simple request. It reaches the server and executes whether or not CORS ultimately lets the page read the response. CORS was never a request-blocking mechanism for this category — it is a response-reading mechanism, and the side effect already happened by the time it runs.
Quick reference
- Simple request criteria: GET/HEAD/POST, safelisted headers only, and one of three specific Content-Type values.
- application/json is NOT one of the three safelisted content types — a JSON POST is not a simple request (see preflight section).
- For a simple request, the server executes before the browser evaluates any CORS header.
- A CORS console error on a simple request means 'the page can't read this' — it does not mean 'the server never saw this.'
- Design any cross-origin-callable endpoint with side effects to be idempotent — a simple request can be retried by the caller without knowing the first attempt actually landed.
Remember this
For a simple request, CORS blocks the page from reading the response, not the server from executing it — a 'CORS blocked' error on a POST can still mean the side effect happened.
Preflighted Requests: OPTIONS Asks Permission First
Any request outside the simple-request rules — a PUT, PATCH, or DELETE, a custom header like Authorization or X-Request-Id, or (very commonly) a JSON body sent with Content-Type: application/json — triggers a preflight. Before sending the real request, the browser sends an OPTIONS request carrying Access-Control-Request-Method and Access-Control-Request-Headers, asking the server whether the real request would be allowed.
The server answers with Access-Control-Allow-Origin, Access-Control-Allow-Methods, and Access-Control-Allow-Headers on the OPTIONS response — no body, no side effect, nothing executed yet. Only if that preflight response permits the method and headers does the browser send the actual request. This is the opposite ordering from a simple request: here, a rejected preflight means the real request is never sent at all. Access-Control-Max-Age lets the browser cache a successful preflight result so repeated calls skip the extra round trip until it expires.
Quick reference
- Preflight triggers: any method besides GET/HEAD/POST, any non-safelisted header, or a Content-Type outside the simple three.
- The OPTIONS preflight carries no body and causes no server-side side effect — it is purely a permission check.
- A rejected preflight means the real request is never sent — the opposite failure mode from a simple request.
- Access-Control-Max-Age caches the preflight result client-side, in seconds, to avoid a round trip on every call.
- A missing route handler for OPTIONS on your API framework is one of the most common causes of a preflight failing outright.
Remember this
A preflight OPTIONS request asks permission with no side effect and no body — only a request that clears it goes out for real, the reverse of how a simple request behaves.
Credentialed Requests and Configuring CORS Correctly
By default, fetch does not send cookies cross-origin at all. Setting credentials: "include" tells the browser to attach cookies to a cross-origin request — and the server must respond with Access-Control-Allow-Credentials: true for the browser to let the page read the result. Critically, the CORS specification forbids combining a wildcard Access-Control-Allow-Origin: * with Access-Control-Allow-Credentials: true. A credentialed cross-origin endpoint must echo back one specific, validated origin — never a wildcard.
That means a server accepting credentialed requests from more than one origin needs to read the request's Origin header, check it against an allowlist, and reflect back exactly that value (never an unvalidated echo of whatever Origin header arrived, which would defeat the purpose entirely). Reading a custom response header client-side needs one more opt-in: by default JavaScript can only read a small safelisted set of response headers (Content-Type, Content-Length, and a few others) — anything else needs Access-Control-Expose-Headers naming it explicitly.
Quick reference
- credentials: 'include' on fetch is required to send cookies cross-origin — the default is no cookies at all.
- Access-Control-Allow-Credentials: true cannot be paired with a wildcard Access-Control-Allow-Origin — the spec forbids it.
- Reflect the Origin header only after checking it against an allowlist — an unconditional reflect is a real vulnerability, not a shortcut.
- Add Vary: Origin when your allowed-origin response varies, so intermediate caches don't serve one origin's CORS headers to another.
- Access-Control-Expose-Headers is required for JavaScript to read any response header outside the small default safelist.
- Cookie-based cross-origin credentials also depend on SameSite settings — see Sessions, JWTs, and OAuth/OIDC for how each auth model treats cross-origin cookies.
Remember this
Credentialed cross-origin access requires a specific, allowlist-validated origin echoed back — never a wildcard, and never an unconditional reflection of whatever Origin header arrived.
Recover One Blocked-Response, Already-Executed Incident
Trigger. A new dashboard at https://dashboard.example.com calls POST https://api.example.com/widgets/42/like with a form-urlencoded body — a simple request — before anyone configured CORS headers on the API. Symptom. The browser console shows a CORS error and the JavaScript catch block reports failure to the user, so the team assumes the click did nothing. Support tickets report like counts silently climbing anyway.
Root mechanism. The request was simple, so it reached the server, ran the handler, and incremented the counter — the browser only blocked the response from being read by the calling page's JavaScript, well after the increment already happened. Recovery: add Access-Control-Allow-Origin: https://dashboard.example.com to the API's response and confirm the same button click now both increments the counter and updates the UI. Verification: query the widget's like count directly before and after a blocked-looking click to confirm the increment already occurred even during the failure window. Prevention: treat any cross-origin-callable endpoint with a side effect as one that can be called without its response ever being read — make it idempotent (an operation key, a check-before-write) so a retry triggered by an apparent CORS failure can't double the effect.
Quick reference
- Trigger: a simple cross-origin request with a side effect, called before CORS headers were configured.
- Symptom: the browser reports failure to the page even though the server-side effect already committed.
- Root mechanism: CORS blocked the response read, not the request execution, for a simple request.
- Recovery: add the correct Access-Control-Allow-Origin header — the fix is server config, not client retry logic.
- Prevention: make cross-origin-callable side effects idempotent, since a caller can never safely assume a failure means nothing happened.
Remember this
A CORS failure on a simple request tells you nothing about whether the server-side effect happened — the only safe assumption is that it might have, so the endpoint needs to be idempotent.
Key takeaway
The same-origin policy is the browser's default: JavaScript cannot read a cross-origin response unless the server explicitly allows it. For a simple request, that check happens after the server already executed the handler — CORS blocks the read, not the request. For anything else, a preflight OPTIONS call checks permission first and blocks the real request outright if denied. Credentialed cross-origin access needs a specific, validated origin echoed back, never a wildcard.
Practice (25 min): stand up two local servers on different ports — one serving a page, one serving an API with no CORS headers configured. Send a simple form-urlencoded POST with a visible side effect (a counter) from the page and confirm the counter increments even though the browser reports a CORS error. Add a correct, allowlist-based Access-Control-Allow-Origin header and confirm the page can now read the response. Switch the request to a JSON body and observe the preflight OPTIONS call appear before the real request. Pass when you can explain, for each of the three cases, whether the server executed anything before the browser made its decision.
Related Articles
Explore this topic