You’ve built a React frontend. You’ve built a Node.js API. They work perfectly when you test them separately. You wire them together, make your first API call, and…
Access to fetch at 'http://localhost:3001/api/users' from origin
'http://localhost:3000' has been blocked by CORS policy:
No 'Access-Control-Allow-Origin' header is present on the requested resource.Sound familiar? Every developer hits this wall. And most developers respond by Googling “how to fix CORS” and pasting app.use(cors()) without understanding what they just did.
Let me actually explain what’s happening.
The Real Problem: Same-Origin Policy#
CORS isn’t the problem. The Same-Origin Policy is the “problem” (it’s actually a feature, but it feels like a problem). The Same-Origin Policy (SOP) is a browser security mechanism that prevents web pages from reading responses to cross-origin requests.
An “origin” is the combination of protocol, hostname, and port:
https://example.comis one originhttps://api.example.comis a different origin (different subdomain)http://example.comis a different origin (different protocol)https://example.com:3000is a different origin (different port)
Without SOP, any website you visit could make requests to your bank, your email, or any other service you’re logged into — and read the responses. That would be catastrophically bad.
So the browser blocks cross-origin requests. Your React app at localhost:3000 tries to fetch data from your API at localhost:3001, and the browser says “nope.”
CORS: The Controlled Exception#
CORS (Cross-Origin Resource Sharing) is how you tell the browser, “It’s okay, I actually want this origin to access my API.”
It works through HTTP headers. Your API server sends specific headers that tell the browser which origins are allowed to access it. If the browser sees the right headers, it allows the request through. If not, it blocks the response.
The important thing to understand: CORS is enforced by the browser, not the server. The server doesn’t reject the request. The server processes it normally and returns a response. But the browser intercepts the response and refuses to let your JavaScript code read it.
This is why CORS errors only appear in the browser. If you make the same request with Postman or curl, it works fine. That’s not a bug — it’s by design.
Simple Requests vs Preflight Requests#
Not every cross-origin request triggers a CORS check. The browser categorizes requests into two types:
Simple Requests#
A “simple request” uses a simple method (GET, HEAD, POST), only uses CORS-safelisted headers (Accept, Accept-Language, Content-Language, Content-Type with text/plain), and doesn’t have special Content-Type values.
For simple requests, the browser just sends the request with an Origin header and checks the response for Access-Control-Allow-Origin.
Preflight Requests#
Anything that’s not a simple request triggers a preflight. The browser first sends an OPTIONS request to ask “Hey, am I allowed to make this request?” Only if the server responds with the right headers does the browser send the actual request.
Things that trigger preflight:
- Using
PUT,DELETE,PATCHmethods - Setting
Content-Type: application/json(yes, JSON is not considered “simple”) - Adding custom headers like
Authorization - Using
XMLHttpRequest.upload
Most modern APIs trigger preflight requests because they use JSON and custom headers. It’s normal.
The Key CORS Headers#
Here are the headers that matter, explained in plain English:
Access-Control-Allow-Origin — “Which origin is allowed to access my API?”
Access-Control-Allow-Origin: https://my-app.comAccess-Control-Allow-Methods — “Which HTTP methods are allowed?”
Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONSAccess-Control-Allow-Headers — “Which custom headers can the request include?”
Access-Control-Allow-Headers: Content-Type, Authorization, X-API-KeyAccess-Control-Allow-Credentials — “Can the request include cookies?”
Access-Control-Allow-Credentials: trueAccess-Control-Max-Age — “How long (in seconds) can the browser cache this preflight result?”
Access-Control-Max-Age: 86400Access-Control-Expose-Headers — “Which response headers can JavaScript actually read?”
Access-Control-Expose-Headers: X-Total-Count, X-Request-IdThe Wildcard Gotcha#
You might be tempted to use Access-Control-Allow-Origin: * to allow everything. And for public APIs, that works fine. But there’s a catch: you cannot use * with credentials.
If your frontend sends cookies or an Authorization header (which most authenticated apps do), you MUST specify the exact origin. No wildcards.
// This will NOT work with credentials:
Access-Control-Allow-Origin: *
Access-Control-Allow-Credentials: true
// This WILL work:
Access-Control-Allow-Origin: https://my-app.com
Access-Control-Allow-Credentials: trueThis is a common source of confusion and frustration.
What CORS Doesn’t Do#
CORS doesn’t protect your API. Your API needs its own authentication and authorization. CORS only protects the user’s browser from having their data read by other websites.
If someone writes a Python script or a mobile app that calls your API, CORS doesn’t apply. CORS is a browser feature, period.
Check Your CORS Configuration#
Use headertest.com to verify your CORS headers are properly configured. It takes seconds and will tell you exactly what’s wrong.