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.
