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.

In this post
  • 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 OPTIONS call
  • 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.

The sentence that makes it click

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.

the-same-request.txt
# 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.

preflight.txt
# 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 to true if 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.
The one everyone gets wrong

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.

server.js
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,
}));
Don't "fix" it with a proxy hack

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 OPTIONS request. 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:3000localhost:5173, and httphttps.
  • 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-Origin can't be * — it must be the exact origin, and Allow-Credentials must be true.

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.