CORS, finally explained — why it works in Postman but not your browser
You've done this. The API call works perfectly in Postman. You copy the
exact same request into your frontend, hit refresh, and the console lights
up red: blocked by CORS policy. Same URL, same headers, same
everything — one works, one doesn't. It feels like the server is personally
offended by your browser.
Here's the twist nobody tells you at first: the server never blocked you. Your browser did. CORS isn't a server refusing your request — it's your browser refusing to hand you the answer, to protect you. Once that clicks, the whole thing stops being mysterious. Let's make it click.
- What CORS actually is — and the browser-only enforcement that explains everything
- Why Postman, curl, and your backend never see a CORS error
- Simple vs. preflighted requests, and what triggers the
OPTIONScall - The headers that actually matter — and the one everyone gets wrong
- A debugging checklist for the next time the console goes red
What CORS actually is
Browsers run every page under a rule called the
Same-Origin Policy: a script loaded from one origin can
only freely read responses from that same origin. An origin is the
trio of scheme + host + port — so
https://app.example.com, http://app.example.com,
and https://app.example.com:8080 are three different origins,
even though they look almost identical.
That rule is why your frontend at localhost:3000 can't just
read a response from api.example.com. By default, the browser
won't let it. CORS — Cross-Origin Resource Sharing — is the
opt-in. It's a set of HTTP headers the server sends to say "yes, I
explicitly allow this other origin to read my responses."
So CORS isn't a wall. It's a permission slip the server writes, and the browser checks. No slip, no reading the response — even though the response arrived perfectly fine.
CORS protects users, not servers. Your API is still reachable by
anyone with curl. CORS just stops a malicious site's
JavaScript from reading your API's responses through an unsuspecting
visitor's browser.
Why Postman works and your browser doesn't
This is the part that makes people doubt their sanity, so let's nail it down. The Same-Origin Policy and CORS are enforced only by browsers. They're a browser feature. Postman, curl, httpie, your backend service calling another backend — none of them are browsers, so none of them apply the rule.
When you hit the API from Postman, the request goes out, the response comes back, Postman shows it to you. No origin check, no permission slip required. When your frontend JavaScript makes the same call, the browser intercepts the response, looks for the CORS headers, finds none, and withholds the data from your code. The request still reached the server. The server still answered. Your browser just refused to deliver.
# In Postman / curl — always works, no CORS involved
curl https://api.example.com/users
# → 200 OK, full JSON, you see everything
# In your frontend JS — browser applies the Same-Origin Policy
fetch("https://api.example.com/users")
.then(r => r.json()) // ← never runs
// → console: "blocked by CORS policy: No 'Access-Control-Allow-Origin'"
That's the whole mystery. The server behaved identically both times. The difference is entirely on the client — one client enforces the rule, the other doesn't.
Simple requests vs. preflighted requests
Not every cross-origin request is treated the same. The browser splits them
into two categories, and knowing which is which explains that mysterious
OPTIONS request you keep seeing in the Network tab.
Simple requests
A request is "simple" if it uses GET, HEAD, or
POST, sends only a small whitelist of headers, and (for POST)
sends a body of type text/plain,
multipart/form-data, or
application/x-www-form-urlencoded. For these, the browser just
sends the request and checks the CORS headers on the response. One round
trip.
Preflighted requests
Anything else — a PUT, a DELETE, a
Content-Type: application/json body, or a custom header like
Authorization — is not simple. Before sending it, the
browser fires a preliminary OPTIONS request to ask the server,
"am I allowed to do this?" That's the preflight. Only if the server answers
yes does the real request go out. Two round trips.
# 1 · the browser asks permission first (you didn't write this call)
OPTIONS /users HTTP/1.1
Origin: https://app.example.com
Access-Control-Request-Method: DELETE
Access-Control-Request-Headers: authorization
# 2 · the server's answer
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET, POST, DELETE
Access-Control-Allow-Headers: authorization
# 3 · only now does your real DELETE go out
This is why adding one Authorization header or switching a
POST to send JSON can suddenly break a call that worked
yesterday — you quietly moved it from the "simple" bucket into the
"preflighted" bucket, and the server wasn't answering the new
OPTIONS question.
The headers that actually matter
CORS is just a handful of headers. The server sends the
Access-Control-* ones; the browser sends Origin
and the Access-Control-Request-* ones during preflight. Here's
the set you'll actually deal with:
Access-Control-Allow-Origin— the big one. Which origin(s) may read the response. Either a specific origin (https://app.example.com) or*for "anyone."Access-Control-Allow-Methods— which HTTP methods are permitted (relevant to preflight).Access-Control-Allow-Headers— which custom request headers the client may send (e.g.authorization).Access-Control-Allow-Credentials— set totrueif the request may carry cookies or auth.Access-Control-Max-Age— how long the browser may cache the preflight answer, so it doesn't ask on every request.
You cannot use Access-Control-Allow-Origin: * together with
Access-Control-Allow-Credentials: true. The browser rejects
that combination outright. If you need to send cookies or auth
cross-origin, Allow-Origin must name the exact origin — never
the wildcard.
And a subtle one that bites people: Access-Control-Allow-Origin
must exactly match the Origin the browser sent — scheme
and port included. http://localhost:3000 and
http://localhost:5173 are different origins, and so are
http and https versions of the same host.
Fixing it — the right way and the trap
The fix almost always belongs on the server: configure it
to send the right Access-Control-Allow-* headers. In Express
that's usually the cors middleware; most frameworks and reverse
proxies (nginx, your CDN) have an equivalent. You tell it which origins,
methods, and headers to allow, and it writes the permission slip on every
response.
const cors = require("cors");
app.use(cors({
origin: "https://app.example.com", // exact origin, not *
methods: ["GET", "POST", "DELETE"],
allowedHeaders: ["authorization", "content-type"],
credentials: true,
}));
A common shortcut is routing requests through your own backend or a dev-server proxy to dodge CORS. That's fine as a temporary local-dev convenience — but it hides the real configuration, and it won't exist in production. Fix the headers at the source; don't route around them.
During local development, a dev-server proxy (Vite's
server.proxy, Create React App's proxy field) is a
perfectly reasonable stopgap so you can keep building. Just remember it's a
stand-in: the production API still needs real CORS headers, because the proxy
disappears the moment you deploy.
A debugging checklist for the red console
Next time a call works in Postman but not the browser, walk this list before you change anything:
- Read the actual error. The console names the missing header — usually
Access-Control-Allow-Origin. That tells you exactly what the server didn't send. - Check the Network tab for an
OPTIONSrequest. If there's a preflight, look at its response — a failed preflight blocks the real request before it even fires. - Compare origins exactly. Scheme, host, and port must match what the server allows.
localhost:3000≠localhost:5173, andhttp≠https. - Did you add a header or change the method? That may have promoted a simple request to a preflighted one the server isn't answering.
- Sending credentials? Then
Allow-Origincan't be*— it must be the exact origin, andAllow-Credentialsmust betrue.
Nine times out of ten it's the first or third item. And if the response code itself is the problem rather than the headers — a genuine 401 or 403 — that's a different diagnosis entirely.
Wrap-up
CORS isn't the server rejecting you — it's your browser enforcing the
Same-Origin Policy and checking for a permission slip the server writes in
its Access-Control-* headers. Postman never enforces the rule,
which is why it always "works." Simple requests get checked on the response;
everything else triggers a preflight OPTIONS first. Fix it on
the server, name your origins exactly, and never pair the wildcard with
credentials.
If your cross-origin call is failing with an auth error rather than a CORS error, the token itself is usually the culprit — decode it with the JWT decoder and the JWT post will tell you which claim is lying. And when the code in the response is what's confusing you, the HTTP status lookup has all 71 of them, with who's responsible for each.