Skip to content

CORS Explained Without Confusion

CoreConceptJuly 21, 20267 min read

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.

Same-origin policy blocks reads by default; CORS headers open a specific hole
Same-origin policy blocks reads by default; CORS headers open a specific hole

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.

Malicious page riding a victim's ambient cookies — what SOP prevents
Malicious page riding a victim's ambient cookies — what SOP prevents

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.

Simple request: server executes, browser decides whether JS may read it after
Simple request: server executes, browser decides whether JS may read it after

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.
A 'blocked' simple request that still ran server-side
1// dashboard.example.com calling api.example.com — a simple request2await fetch("https://api.example.com/widgets/42/like", {3  method: "POST",4  headers: { "Content-Type": "application/x-www-form-urlencoded" },5  body: "source=dashboard",6});7// Console shows: "blocked by CORS policy: no Access-Control-Allow-Origin"8// But the server already incremented the like counter — the POST reached it.
Server opts the dashboard origin in
1// api.example.com response headers2// Access-Control-Allow-Origin: https://dashboard.example.com3//4// Now the same fetch() call succeeds AND the page can read the response —5// the request itself was never the problem; reading the result was.

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.

Preflight: OPTIONS asks permission before the real request is ever sent
Preflight: OPTIONS asks permission before the real request is ever sent

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.
JSON POST triggers a preflight — not a simple request
1await fetch("https://api.example.com/widgets", {2  method: "POST",3  headers: { "Content-Type": "application/json" }, // not safelisted4  body: JSON.stringify({ name: "New widget" }),5});6// Browser sends OPTIONS first:7//   Access-Control-Request-Method: POST8//   Access-Control-Request-Headers: content-type
Server's preflight response
1// OPTIONS /widgets response — no body, nothing executed server-side yet2// Access-Control-Allow-Origin: https://dashboard.example.com3// Access-Control-Allow-Methods: GET, POST, PUT, DELETE4// Access-Control-Allow-Headers: Content-Type5// Access-Control-Max-Age: 6006//7// Only after this succeeds does the browser send the real JSON POST.

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.

Credentialed requests need a specific, validated origin — never a wildcard
Credentialed requests need a specific, validated origin — never a wildcard

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.
Insecure: reflecting any Origin unconditionally
1// DO NOT DO THIS — allows every origin to make credentialed requests2app.use((req, res, next) => {3  res.setHeader("Access-Control-Allow-Origin", req.headers.origin);4  res.setHeader("Access-Control-Allow-Credentials", "true");5  next();6});7// Any site can now ride a victim's cookies through this endpoint.
Validated allowlist echo
1const ALLOWED_ORIGINS = new Set([2  "https://dashboard.example.com",3  "https://admin.example.com",4]);5 6app.use((req, res, next) => {7  const origin = req.headers.origin;8  if (origin && ALLOWED_ORIGINS.has(origin)) {9    res.setHeader("Access-Control-Allow-Origin", origin); // specific, not "*"10    res.setHeader("Access-Control-Allow-Credentials", "true");11    res.setHeader("Vary", "Origin"); // caches must not mix responses across origins12  }13  next();14});

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.

widget-42: like counter increments despite a 'blocked by CORS' console error
widget-42: like counter increments despite a 'blocked by CORS' console error

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.
Client code assuming a CORS error means nothing happened
1try {2  await fetch("https://api.example.com/widgets/42/like", {3    method: "POST",4    headers: { "Content-Type": "application/x-www-form-urlencoded" },5    body: "source=dashboard",6  });7} catch (err) {8  // Wrong assumption: "the like never landed, safe to let the user retry freely"9  showError("Could not like this widget");10}
Idempotent endpoint — safe regardless of whether the response is ever read
1// Server: require a client-generated operation key, ignore repeats2app.post("/widgets/:id/like", async (req, res) => {3  const opKey = req.body.opKey;4  const already = await likeOps.exists(opKey);5  if (already) return res.sendStatus(204); // repeat click — no-op6 7  await likeOps.record(opKey);8  await widgets.incrementLikes(req.params.id);9  res.sendStatus(204);10});11// A retry after a perceived CORS failure can no longer double-count.

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.

Share:

Related Articles

You press Enter on https://shop.example/products/42. A moment later, a product page appears. That small action crosses n

Read

HTTPS and a valid JWT only prove the front door locked. Many real API incidents happen after authentication succeeds: a

Read

A well-designed REST API is a contract. Clients depend on its URLs, error shape, and pagination for years; an accidental

Read

Explore this topic

Keep learning

Follow a structured path or browse all courses to go deeper.