Skip to main content
  1. Posts/

Why Postman Works But Your Browser Doesn't

·1360 words·7 mins
Photograph By Mick Haupt
Blog Web Development Security
Table of Contents

It’s 11pm. Your React frontend is running on localhost:5173. Your API is running on localhost:8080. You hit the endpoint from Postman — 200 OK, beautiful JSON, everything works. You hit the same endpoint from your frontend — red text in the console, something about “Access-Control-Allow-Origin,” and an undefined response body. You paste the error into Google, add Access-Control-Allow-Origin: * to your server, and move on.

I’ve done this. You’ve done this. We’ve all done this. But that quick fix papers over a mechanism that’s genuinely worth understanding — because when CORS breaks in production (and it will), the fix isn’t always a wildcard.

Netscape Started This (In 1995)
#

To understand CORS, you have to understand the thing it relaxes: the Same-Origin Policy. SOP shipped in Netscape Navigator 2.0 in 1995, at the exact same time as JavaScript. The timing isn’t a coincidence — the moment browsers could run scripts that make network requests and read the DOM, they needed a boundary to prevent one site from reading another site’s data.

An “origin” is three things: scheme + host + port. https://example.com:443 is one origin. Change any of the three — swap https for http, change example.com to api.example.com, use port 8080 instead of 443 — and you’ve got a different origin.

That subdomain thing catches people: app.example.com and api.example.com are cross-origin, even though they share a root domain. This is the exact scenario that produces most CORS errors in the wild.

Without SOP, a malicious ad on a news site could silently fetch your Gmail inbox using your authenticated cookies. A phishing page could read your bank balance from another tab. SOP prevents that by blocking JavaScript from reading cross-origin responses. The web as a platform for banking, email, and private data depends on this one rule.

The Most Misunderstood Error on the Internet
#

Here’s the thing that confuses everyone: CORS errors don’t mean the server blocked your request. The request was sent. The server received it, processed it, and returned a response. The browser just won’t let your JavaScript read that response because the server didn’t include the right headers.

What actually happens:

1. Your JS calls fetch('https://api.example.com/data')
2. Browser sends the request (yes, it leaves your machine)
3. Server processes it, returns 200 OK with data
4. Browser checks: does the response have Access-Control-Allow-Origin?
5. Nope → browser hides the response from your JavaScript
6. Console: "has been blocked by CORS policy"

This is why Postman works. Postman isn’t a browser — it doesn’t enforce SOP. Neither does curl, nor server-to-server HTTP calls, nor mobile apps making direct API requests. CORS is exclusively a browser-enforced protocol, because browsers are the only environment that runs untrusted JavaScript from arbitrary origins.

The server’s CORS headers are essentially a permission slip: “yes, I expect requests from https://app.example.com, and it’s OK for their JavaScript to read my responses.”

The OPTIONS Interrogation
#

Now for the part that really baffles people: sometimes the browser sends two requests instead of one. Before your actual PUT /api/users with Content-Type: application/json, the browser sends an OPTIONS /api/users — a “preflight” request asking the server for permission.

Not every request gets a preflight. The browser divides cross-origin requests into “simple” and “not simple”:

Simple (no preflight): GET, HEAD, or POST — but only if the headers are basic (Accept, Content-Language, Content-Type) and the content type is one of application/x-www-form-urlencoded, multipart/form-data, or text/plain.

Not simple (preflight required): anything else. PUT, DELETE, PATCH, any custom header like Authorization, or Content-Type: application/json.

Why this specific split? It’s actually brilliant: the “simple” criteria match exactly what an HTML <form> could already do before JavaScript existed. A form could always POST application/x-www-form-urlencoded data to any URL. SOP never blocked that, so CORS doesn’t add a preflight for it either. The preflight only kicks in for requests that JavaScript made newly possible — the ones that couldn’t happen before the fetch API.

Here’s the preflight dance:

Browser                              Server
  │                                    │
  │── OPTIONS /api/users ─────────────>│  "Can I PUT with JSON + Auth?"
  │   Origin: https://app.com          │
  │   Access-Control-Request-Method:   │
  │     PUT                            │
  │   Access-Control-Request-Headers:  │
  │     Content-Type, Authorization    │
  │                                    │
  │<── 204 ────────────────────────────│  "Yes, here's what I allow"
  │   Access-Control-Allow-Origin:     │
  │     https://app.com               │
  │   Access-Control-Allow-Methods:    │
  │     GET, POST, PUT, DELETE        │
  │   Access-Control-Max-Age: 86400   │
  │                                    │
  │── PUT /api/users ─────────────────>│  (actual request, now permitted)
  │   { "name": "Aaron" }             │

That Access-Control-Max-Age: 86400 header tells the browser to cache the preflight result for 24 hours. Without it, every single non-simple request fires two HTTP calls — your API traffic doubles for no reason. (Chrome caps the cache at 2 hours regardless of what you set, because of course it does.)

The Credentials Trap
#

By default, cross-origin requests don’t send cookies or auth headers. If you want them included:

// Client: explicitly opt in
fetch("https://api.example.com/data", {
  credentials: "include", // now cookies are sent cross-origin
});

But this comes with a hard rule: the server cannot respond with Access-Control-Allow-Origin: * when credentials are involved. It must be the specific origin:

Access-Control-Allow-Origin: https://app.example.com  # specific, not *
Access-Control-Allow-Credentials: true

This is deliberate. If * worked with credentials, any website on the internet could make authenticated requests to your API and read the responses. That’s not a CORS misconfiguration — that’s a data breach waiting to happen.

The Config Across Stacks
#

I’ve set up CORS in Spring Boot, Express, and nginx at this point, and the patterns are similar but the gotchas are stack-specific.

In Express, the cors package handles OPTIONS automatically — you barely think about it:

import cors from "cors";
app.use(
  cors({
    origin: "http://localhost:5173", // Vite dev server
    credentials: true,
  }),
);

In Spring Boot, forgetting to include OPTIONS in allowedMethods is a classic silent failure — preflights just return 403 and the console error gives you nothing useful:

registry.addMapping("/api/**")
    .allowedOrigins("http://localhost:5173")
    .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")  // don't forget OPTIONS
    .allowCredentials(true);

And in nginx, the sneakiest bug is configuring CORS in both nginx and the app server, which produces duplicate Access-Control-Allow-Origin headers. The browser rejects that too. Pick one place to handle CORS — never both.

“Wait, We Don’t Even Need CORS?”
#

Here’s the plot twist most tutorials skip: in production, you often don’t need CORS at all. If your frontend and API are served from the same origin via a reverse proxy, there’s no cross-origin request:

Development (CORS needed):
  localhost:5173  ──fetch──>  localhost:8080  ← different ports = different origin

Production (no CORS):
  example.com  ──fetch──>  example.com/api/
       │                        │
       └── nginx serves         └── nginx proxies to backend:8080
           static files

Same scheme, same host, same port. Same origin. No CORS headers needed. The browser never even checks. This is the pattern most production deployments end up using — nginx (or Cloudflare, or your platform’s edge proxy) sits in front of everything, making the frontend and API appear as one origin.

CORS configuration then becomes a development-only concern: your Vite dev server on port 5173 and your API on port 8080 are different origins. Once you deploy behind a proxy, the problem dissolves.

Don’t Do This (Please)
#

A few CORS configurations that will make your security team cry:

Reflecting any origin: Server blindly echoes back whatever Origin header the browser sends, with credentials: true. This is equivalent to no security at all. Any malicious site can read your authenticated API responses.

Trusting null origin: The null origin sounds harmless, but attackers can trigger it from sandboxed iframes. If your server trusts Origin: null with credentials, you have a vulnerability.

Suffix matching: if (origin.endsWith('.example.com')) also matches evil-example.com. Always validate the full origin string, not just a suffix.

Origin Story (Pun Intended)
#

CORS is one of those things that seems like a nuisance until you understand what it’s protecting. The Same-Origin Policy has been the security backbone of the web since 1995 — before cookies had SameSite, before CSP existed, before CSRF tokens were standard practice. CORS doesn’t weaken that protection; it gives servers a structured way to poke specific, controlled holes in it.

Next time you see that red console error, at least you’ll know: your server got the request just fine. It’s the browser that’s looking out for you.

Aaron Yong
Author
Aaron Yong
Building things for the web. Writing about development, Linux, cloud, and everything in between.

Related

The Server Always Wins
·1405 words·7 mins
Photograph By Kelvin Ang
Blog Web Development Architecture
How meta-frameworks like Next.js and TanStack Start achieve their SEO and performance benefits — it’s not magic, it’s architecture.
Sharing Is Caring
·1948 words·10 mins
Photograph By Elaine Casap
Blog JavaScript Web Development
Internal packages in monorepos — shared types, UI components, and the end of copy-paste engineering
One Repo to Rule Them All
·1171 words·6 mins
Photograph By Heather Wilde
Blog JavaScript Web Development
Turborepo, monorepo architecture, and why rebuilding everything on every change is a waste of everyone’s time