# CORS, Explained (and Why It Keeps Blocking You)

> What CORS actually is - the browser protecting users by refusing to let one site read another's responses - how to read the error and the headers, and how to fix it on the server without opening a security hole.


---

# CORS, Explained (and Why It Keeps Blocking You)

You wrote some perfectly reasonable code. Your frontend on `localhost:5173` calls your API on
`localhost:3000`, and the browser slams the door: *"blocked by CORS policy."* You can see the response
sitting right there in the Network tab - the API answered fine - but your JavaScript can't touch it. It
feels like the browser is sabotaging you for no reason.

It isn't. CORS is a safety rule the browser enforces *on behalf of the person using it*, and once you see
what it's actually protecting against, the error stops being mysterious and the fix becomes obvious.

## How to read this

- **Blocked right now and just need the fix?** Jump to [Phase 3: Fixing It Properly](03-fixing-it-properly.md)
  and use the cheat-card at the top - but read the one ⚠️ about `*` before you ship it.
- **Want it to finally make sense?** Read in order. Each phase builds on the last: the *why* (Phase 1), the
  *what you're seeing* (Phase 2), then the *fix* (Phase 3).

## The phases

1. **[Why the Browser Blocks You](01-why-the-browser-blocks-you.md)** - the same-origin policy, and the
   one idea that makes all of CORS click: the browser enforces, the server permits.
2. **[Reading the Error & the Headers](02-reading-the-error-and-the-headers.md)** - decode the console
   message, the `Origin` request header, the `Access-Control-Allow-Origin` response header, and the
   preflight `OPTIONS` request.
3. **[Fixing It Properly](03-fixing-it-properly.md)** - set the right headers on the *server*, why
   credentials and wildcards don't mix, and a symptom-to-fix cheat-card.

> Deeper material - fine-grained per-route policies, caching preflights with `Access-Control-Max-Age`,
> and CORS in front of CDNs and gateways - is deferred to a follow-up guide. This one gets you unblocked,
> safely.

## Related

- [HTTP, Explained](/guides/http-explained)
- [What an API Is](/guides/what-an-api-is)


---

# Why the Browser Blocks You

Before you touch a single header, you need one idea in your head - because every confusing thing about
CORS comes from *not* having it: **the browser is enforcing a rule to protect the person sitting in front
of it, and CORS is how the server tells the browser to relax that rule for specific friends.** That's the
whole game. Let's build up to it.

## What an "origin" actually is

📝 **Origin.** An origin is the combination of three things: the **scheme** (`http` or `https`), the
**host** (`example.com`, `localhost`), and the **port** (`443`, `3000`, `5173`). All three must match for
two URLs to share an origin.

This trips people up constantly, so here is the picture:

```text
   https :// api.example.com : 443 / users
   ─────    ───────────────   ───
   scheme       host          port      ← change ANY of these three…
                                          …and it's a DIFFERENT origin.
```

So these are all *different* origins from `http://localhost:5173`:

```text
   http://localhost:3000      ← different PORT      (the classic dev setup)
   https://localhost:5173     ← different SCHEME    (http vs https)
   http://127.0.0.1:5173      ← different HOST      (127.0.0.1 is not "localhost")
```

⚠️ **The one that bites everyone in dev:** `localhost` and `127.0.0.1` are *not* the same origin, even
though they point at the same machine. If your frontend uses one and your API uses the other, the browser
treats them as strangers and CORS kicks in. Pick one and use it on both sides.

## The same-origin policy - the rule underneath everything

**What it actually is.** The **same-origin policy** is a rule baked into every browser: JavaScript running
on one origin **cannot read the response** from a request to a *different* origin - unless that other
origin explicitly allows it.

**Why people get this wrong.** People assume the request was *blocked*. Usually it wasn't. The browser
often sends the request, the server answers, and then - at the last moment - the browser refuses to hand
the response body to your JavaScript. The data came back; your code just isn't allowed to see it. That's
why the Network tab can show a `200 OK` while your `fetch` throws.

**Why this rule exists at all.** Imagine you're logged into your bank in one tab. Your browser holds a
cookie that proves it's you. Now you open a sketchy tab with this on the page:

```mermaid
sequenceDiagram
  participant Evil as evil.example.com
  participant Browser
  participant Bank as yourbank.com
  Evil->>Browser: fetch("yourbank.com/account/balance")
  Browser->>Bank: request + your bank cookie (attached automatically)
  Bank-->>Browser: your balance
  Note over Browser,Evil: Same-origin policy blocks Evil's JS<br/>from reading the response
```

The same-origin policy is what stops that. The evil page can *send* the request, but the browser will not
let the evil page's JavaScript *read* the answer. Your money stays private. This is the entire reason the
policy exists - it protects a logged-in user from having their data siphoned by whatever random site they
happen to be visiting.

💡 **Hold onto this:** the same-origin policy protects the *user*, by refusing to let one site read
another site's responses. It is the default. CORS is the exception mechanism layered on top.

## CORS - the server saying "these origins are okay"

**What it actually is.** CORS stands for **Cross-Origin Resource Sharing**. It is a set of HTTP headers
the *server* sends to tell the browser: *"I'm fine with this particular other origin reading my
responses."*

That's the key reversal most people miss. CORS doesn't *block* anything - the same-origin policy already
does the blocking, by default. CORS is how a server **opts specific origins back in**. When your API
answers with `Access-Control-Allow-Origin: http://localhost:5173`, it's telling the browser: *"a page on
`localhost:5173` is allowed to read this - let it through."* The browser sees that permission slip and
hands your JavaScript the response.

```mermaid
flowchart LR
  page["Your page on localhost:5173<br/>(browser ENFORCES the rule)"]
  api["API server on localhost:3000<br/>(server PERMITS exceptions)"]
  page -- "request" --> api
  api -- "response + CORS header<br/>'I allow localhost:5173'" --> page
  page --> check{response says<br/>this origin is OK?}
  check -->|yes| ok([JS gets the data])
  check -->|no| err([browser blocks the read - CORS error])
```

💡 **The whole mental model in one line:** the **browser enforces**, the **server permits**. A CORS error
means the server didn't send the permission the browser was looking for.

## The gotcha that changes how you debug

⚠️ **CORS is not a server-side firewall. It does not protect your API.** This is the single most important
thing to understand, and it's the opposite of what the error *feels* like.

CORS is enforced *by the browser*, for *browser users*. Anything that is not a browser - `curl`, a Python
script, Postman, another server, an attacker with a terminal - completely ignores CORS headers. They will
happily read your API's response no matter what `Access-Control-Allow-Origin` says.

```console
$ curl https://api.example.com/users
[{"id":1,"name":"Ada"},{"id":2,"name":"Grace"}]
```
*What just happened:* `curl` asked your API for `/users` and got the data - no CORS header in sight, no
"blocked" anything. CORS only ever happens inside a browser. So if your `/users` endpoint should not be
public, **CORS won't save you** - you need real authentication and authorization on the server. CORS
decides which *web pages* may read your responses; it says nothing about who is *allowed to call your API*.

**Why this saves you later.** When something is "blocked by CORS," you now know two things instantly: the
problem is a *missing or wrong response header on the server*, not your fetch code - and it only shows up
in the browser, which is why `curl` "works" while the page doesn't. That single insight cuts most CORS
debugging in half.

## Recap

1. An **origin** is scheme + host + port. Change any one and it's a different origin (`localhost` ≠
   `127.0.0.1`; different ports differ too).
2. The **same-origin policy** stops one origin's JavaScript from reading another origin's responses - to
   protect the logged-in user. It's the default.
3. **CORS** is the *server's* way to opt specific origins back in, via response headers.
4. **The browser enforces; the server permits.** A CORS error = the server didn't send the permission.
5. CORS protects *users in browsers*, **not your API**. `curl` and scripts ignore it entirely - so guard
   your API with real auth, not CORS.


---

# Reading the Error & the Headers

The CORS error in the console looks like a wall of jargon, but it's actually telling you exactly what's
wrong - once you know which words to read. This phase decodes the message, shows the two headers the whole
dance comes down to, and meets the surprise extra request (the *preflight*) that confuses people the first
time they spot it in the Network tab.

> ⏭️ New here? Read [Phase 1](01-why-the-browser-blocks-you.md) first - "the browser enforces, the server
> permits" makes everything below land.

## Decoding the classic error

Here's the message you came for, straight from a browser console:

```text
Access to fetch at 'http://localhost:3000/api/users' from origin
'http://localhost:5173' has been blocked by CORS policy: No
'Access-Control-Allow-Origin' header is present on the requested resource.
```

Read it slowly, phrase by phrase - it's a complete diagnosis:

```text
   Access to fetch at 'http://localhost:3000/api/users'   ← WHAT you tried to read
   from origin 'http://localhost:5173'                    ← WHERE your page is running
   has been blocked by CORS policy:                       ← the browser enforced the rule
   No 'Access-Control-Allow-Origin' header is present     ← WHY: the server sent no
       on the requested resource.                            permission slip
```

*What this is telling you:* your page on `localhost:5173` asked `localhost:3000` for data, the browser
checked the response for a permission header, found none, and refused to hand you the body. The fix lives
entirely on the server at `localhost:3000` - it needs to send that header. (Phase 3.)

⚠️ **Don't get sent on a wild goose chase by the word "fetch."** The error is not about your `fetch()`
code being wrong. Your request was fine. The browser blocked the *reading of the response*. No amount of
editing the frontend `fetch` call will fix a missing server header.

You'll see a few variants of this message; they all point at the same server-side cause:

| What the console says | What it actually means |
|---|---|
| `No 'Access-Control-Allow-Origin' header is present` | The server sent no CORS permission at all |
| `The 'Access-Control-Allow-Origin' header has a value '...' that is not equal to the supplied origin` | The server allowed a *different* origin than yours |
| `Response to preflight request doesn't pass access control check` | The preflight `OPTIONS` request was rejected (see below) |
| `...does not have HTTP ok status` | The server answered the preflight `OPTIONS` with an error instead of a success |

## The two headers it all comes down to

CORS, in its simplest form, is a short conversation between exactly two headers.

**The request header - `Origin`.** The browser adds this *automatically* to cross-origin requests. You
don't set it; you can't fake it from JavaScript. It tells the server where the calling page lives.

**The response header - `Access-Control-Allow-Origin`.** The server sends this back to name which origin
is allowed to read the response. The browser compares it against the `Origin` it sent.

Here is a healthy exchange, annotated:

```http
GET /api/users HTTP/1.1
Host: localhost:3000
Origin: http://localhost:5173          ← browser added this: "I'm calling from here"
```
```http
HTTP/1.1 200 OK
Content-Type: application/json
Access-Control-Allow-Origin: http://localhost:5173   ← server: "that origin may read this"

[{"id":1,"name":"Ada"}]
```
*What just happened:* the browser announced its origin, the server echoed back the *same* origin in
`Access-Control-Allow-Origin`, the browser saw a match, and your JavaScript got the JSON. When the server
*omits* that response header - or returns a different origin - the body is the same, but the browser
refuses to let you read it, and you get the console error above.

💡 **Key point:** the browser doesn't trust *intent*, it checks a *header*. The server has to literally
say the magic words in the response. Silence means "no."

## The plot twist: the preflight `OPTIONS` request

The first time you open the Network tab and see an `OPTIONS` request you never wrote, sitting right before
your actual request, it's genuinely baffling. Here's what's going on.

📝 **Preflight request.** For requests that could *change data* or carry unusual headers, the browser
sends a small `OPTIONS` request *first* - a "may I?" - and only sends the real request if the server says
yes. This is the **preflight**.

**Why it exists.** Some requests are too risky to fire blindly. A `DELETE`, or a `POST` with a JSON
content type, or any request with a custom header like `Authorization` could cause real damage if the
server wasn't expecting cross-origin callers. So the browser checks *permission in advance* instead of
sending the dangerous request and apologizing afterward.

**Simple vs. non-simple - what triggers a preflight.** Not every request gets one. A "simple" request
skips the preflight; anything else triggers it.

```text
   SIMPLE (no preflight)              NON-SIMPLE (preflight fires first)
   ─────────────────────              ─────────────────────────────────
   • GET or HEAD or POST          vs. • PUT, DELETE, PATCH
   • only basic headers               • custom headers (e.g. Authorization,
   • POST body is a form or             X-API-Key)
     plain text                        • POST with Content-Type:
                                         application/json
                                       • requests sending credentials in
                                         some cases
```

Most real API calls - a JSON `POST`, anything with an auth token - are *non-simple*, which is why you see
preflights so often. Here's a preflight conversation in full:

```http
OPTIONS /api/users HTTP/1.1
Host: localhost:3000
Origin: http://localhost:5173
Access-Control-Request-Method: POST              ← "I want to do a POST"
Access-Control-Request-Headers: content-type     ← "…and send this header"
```
```http
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: http://localhost:5173   ← "that origin is okay"
Access-Control-Allow-Methods: GET, POST, DELETE      ← "…these methods are okay"
Access-Control-Allow-Headers: content-type           ← "…that header is okay"
```
*What just happened:* before sending your real `POST`, the browser asked the server "I'm from
`localhost:5173`, I want to `POST` with a `content-type` header - allowed?" The server answered "yes, that
origin, those methods, that header." The browser saw approval and *then* sent the actual `POST`. If the
server had not approved the method or header, the browser would stop here - and you'd see *"Response to
preflight request doesn't pass access control check."*

The two-step "may I? / yes / now the real one" shape is the whole idea of a preflight:

```mermaid
sequenceDiagram
  participant Browser
  participant Server
  Browser->>Server: OPTIONS (preflight): may I POST with content-type?
  Server-->>Browser: 204 + Allow-Origin / Allow-Methods / Allow-Headers
  Browser->>Server: the real POST /api/users
  Server-->>Browser: 200 + the data
```

⚠️ **The preflight is a separate request with its own rules.** Your real request can fail at the
preflight stage and never even run. If the error mentions "preflight," the problem is the `OPTIONS`
response - the server isn't allowing your method or your headers, not (yet) the data request itself.

**Why this saves you later.** When you can read the error and know the two headers, debugging becomes
mechanical: open the Network tab, find the failing request (or its `OPTIONS` preflight), look at the
`Origin` it sent, and check whether the response's `Access-Control-Allow-*` headers cover it. The mismatch
is always right there.

## Recap

1. The console error names *where you called from*, *what you tried to read*, and *which header was
   missing* - read it phrase by phrase.
2. "Blocked by CORS" is a **server header problem**, not a bug in your `fetch`.
3. The browser auto-sends **`Origin`**; the server must answer with a matching
   **`Access-Control-Allow-Origin`**.
4. **Non-simple** requests (PUT/DELETE/PATCH, JSON POST, custom headers) trigger a **preflight `OPTIONS`**
   request that must be approved before the real request runs.
5. An error mentioning "preflight" means the `OPTIONS` response didn't allow your method or headers.

Change the method, the server's header, and credentials to see exactly when the browser allows or blocks the response:

```playground-cors
```


---

# Fixing It Properly

Here's the good news you earned in Phases 1 and 2: the fix is almost always a few response headers on the
*server*, not the frontend. The browser is waiting for the server to send a permission slip - so we send
it: a scannable cheat-card for when you're blocked *right now*, the proper fixes underneath, the
credentials trap that catches everyone, and a dev-only proxy for when you can't change the server.

## The cheat-card

> **Match the symptom to the row, then read the section under it. The fix is always on the server unless
> noted.**

| What you're seeing | The calm fix |
|---|---|
| `No 'Access-Control-Allow-Origin' header is present` | Server sends no CORS at all - add `Access-Control-Allow-Origin` for your origin (§1) |
| Header present but `...not equal to the supplied origin` | Server allows a *different* origin - set it to *your* origin, or reflect it (§1) |
| `Response to preflight request doesn't pass access control check` | Add `Access-Control-Allow-Methods` and `Access-Control-Allow-Headers` and answer `OPTIONS` (§2) |
| Cookies/auth not sent, or "credentials" error with `*` | You can't combine credentials with `*` - name the exact origin (§3) |
| Can't touch the server (third-party API, no access) | Use a dev proxy so the browser sees one origin (§4) |
| Works in `curl` / Postman but not the browser | That's expected - CORS is browser-only. Your headers are the fix (Phase 1) |

---

## 1. Allow your origin (the everyday fix)

Most CORS errors are just a server that never sends `Access-Control-Allow-Origin`. You add it to the
response. The cleanest value is the *exact* origin you want to allow:

```http
HTTP/1.1 200 OK
Content-Type: application/json
Access-Control-Allow-Origin: http://localhost:5173
```
*What just happened:* the server now names `http://localhost:5173` as an allowed reader. The browser
compares it to the `Origin` it sent, sees a match, and releases the response to your JavaScript. The error
is gone.

If you need to allow several origins (say dev *and* staging), the server should keep a small **allowlist**
and reflect the request's `Origin` back only when it's on that list:

```mermaid
flowchart TD
  req["request Origin = http://localhost:5173"] --> check{in my allowlist?}
  check -->|no| block["don't send the header<br/>(browser blocks)"]
  check -->|yes| echo["Access-Control-Allow-Origin:<br/>http://localhost:5173 - echo the exact origin"]
```

This is the correct way to support multiple origins. Resist the urge to reach for `*` - the next sections
explain why.

## 2. Make the preflight pass

If your error mentions *preflight*, the server is rejecting the `OPTIONS` "may I?" request (Phase 2). The
server has to answer `OPTIONS` with the methods and headers your real request needs:

```http
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: http://localhost:5173
Access-Control-Allow-Methods: GET, POST, PUT, DELETE
Access-Control-Allow-Headers: Content-Type, Authorization
```
*What just happened:* the server told the browser, ahead of time, that this origin may use those methods
and send those headers. The browser approves the preflight and *then* fires your real request. Two common
trip-ups: the server must actually *handle* the `OPTIONS` route (many frameworks need this enabled), and
`Access-Control-Allow-Headers` must include every custom header you send - `Authorization` and a non-form
`Content-Type` are the usual missing ones.

💡 **Most web frameworks ship a CORS middleware** (a setting or a small package) that sets all of these
for you from one config block. Reach for that rather than writing headers by hand - but you now know
exactly what it's doing under the covers, which is what makes it debuggable.

## 3. The credentials trap (read this before you ship)

📝 **Credentials.** In CORS, "credentials" means cookies, HTTP authentication, or a request sent with
`fetch(url, { credentials: "include" })`. These are how a browser proves *who the user is*.

Here's the rule that catches everyone:

⚠️ **You cannot combine `Access-Control-Allow-Origin: *` with credentials.** If the request sends
credentials, the server **must** name a single, specific origin - the wildcard is rejected by the browser.
This is deliberate: `*` means "any site may read this," and "any site may read this *with the user's
cookies attached*" would re-open the exact hole the same-origin policy exists to close.

```http
HTTP/1.1 200 OK
Access-Control-Allow-Origin: http://localhost:5173    ← MUST be a specific origin, never *
Access-Control-Allow-Credentials: true                ← required to let cookies/auth through
```
*What just happened:* the server allowed one named origin *and* set `Access-Control-Allow-Credentials:
true`, so the browser will both send the user's cookies and let your JavaScript read the response. If the
server had answered `Access-Control-Allow-Origin: *` here, the browser would block it with a credentials
error no matter what else was set.

This is why the multi-origin allowlist from §1 matters: with credentials, *reflecting the exact origin* is
not just tidy - it's the only thing that works.

## The big gotcha: don't `*` your way out of it

⚠️ **`Access-Control-Allow-Origin: *` is not a fix - it's a decision to let every website on the internet
read that response.** It's the first thing people paste in to make the red text go away, and for a truly
public, non-credentialed, read-only endpoint (a public weather feed, say) it's fine. But on anything that
returns user data, sits behind auth, or lives on a private network, `*` is a real security mistake:

- It can't be used with credentials anyway (§3), so it often doesn't even solve your actual problem.
- It tells *every* origin - including malicious ones - that their JavaScript may read your responses. For
  an internal or authenticated API, that's the door you were trying to keep shut.

The safe default is: **name the specific origin(s) you actually trust.** Use `*` only when you genuinely
mean "this is public to the entire web, with no user-specific data."

## 4. The dev workaround: a proxy

Sometimes you *can't* change the server - it's a third-party API, or someone else owns it. In development,
you can sidestep CORS entirely with a **proxy**: your own dev server forwards the request, so the browser
only ever talks to *one* origin (yours).

```mermaid
flowchart LR
  browser["browser<br/>(localhost:5173)"]
  dev["your dev server<br/>(localhost:5173)"]
  api["some-third-party-api.com"]
  browser -- "/api/... (same origin, no CORS)" --> dev
  dev -- "forwards server-side" --> api
  api -- "response" --> dev
  dev -- "still looks same-origin" --> browser
```

*What's happening:* the browser thinks it's talking to its own origin (`localhost:5173`), so the
same-origin policy is satisfied and no CORS headers are needed. The actual cross-origin call happens
*server-to-server*, where CORS doesn't apply at all (remember Phase 1: CORS is browser-only). Most
frontend dev servers have a built-in proxy setting for exactly this.

⚠️ **A proxy is a dev convenience, not a production CORS fix.** In production, the proper answer is still
correct headers on the server you control. Don't ship a hack that papers over a header you could just set.

## Recap

1. The fix is **server-side headers**, not frontend code.
2. Send `Access-Control-Allow-Origin` with your **exact origin**; for several origins, keep an allowlist
   and reflect the matching one.
3. For preflights, also send `Access-Control-Allow-Methods` and `Access-Control-Allow-Headers`, and
   actually handle the `OPTIONS` request.
4. **Credentials + `*` is forbidden** - name a specific origin and add `Access-Control-Allow-Credentials:
   true`.
5. Don't reach for `Access-Control-Allow-Origin: *` on a credentialed or private API - it opens it to the
   whole web. A **dev proxy** is the right *temporary* workaround when you can't change the server.

---

You came here blocked; now you can read the error, point at the exact missing header, and fix it without
quietly opening a hole. That's the whole skill. The deeper material - caching preflights with
`Access-Control-Max-Age`, per-route policies, and CORS in front of CDNs and API gateways - is a follow-up
guide for when you need it. For now, you're unblocked, safely.
