CORS: The Browser's Cross-Origin Resource Sharing Mechanism
An explanation of why browsers block cross-origin requests, how CORS actually works, and a walkthrough of preflight requests, common header configurations, and debugging techniques
If you've ever worked on a frontend, you've probably seen the console throw a wall of red text about a blocked cross-origin request. The weird part is that the exact same API call works fine in Postman — it's only the browser that refuses. Let's dig into CORS, the mechanism everyone loves to hate, and figure out what it's actually blocking and why.
First, Why Does the Browser Block Us in the First Place?
CORS stands for Cross-Origin Resource Sharing. To understand it, you first need to know about a more fundamental browser defense: the Same-Origin Policy.
The rule behind the Same-Origin Policy is simple: two URLs only count as the same origin if the protocol, host, and port all match exactly. Miss even one, and they're different origins. For example:
https://example.comandhttp://example.comare different origins (different protocol)https://example.comandhttps://api.example.comare different origins (different host)https://example.comandhttps://example.com:3000are different origins (different port)
By default, browsers block pages from reading responses from a different-origin server. That sounds annoying, but it's actually protecting you. Imagine you're logged into your bank's website in one tab and a malicious site is open in another. Without this protection, JavaScript on that malicious site could quietly use your login credentials to hit the bank's API and read the response — a classic CSRF (Cross-Site Request Forgery) attack scenario. The Same-Origin Policy is the wall that stops that from happening.
The problem is that modern architectures usually split frontend and backend — your frontend lives at https://myapp.com, your API at https://api.myapp.com, and they're different origins by design. You can't just leave it at that, so CORS steps in: it's a mechanism that lets the server proactively tell the browser, "I trust this origin, let it through."
CORS Isn't a Frontend Problem — It's the Server's Permission Slip
This is the most common misunderstanding: assuming a CORS error means the frontend code is wrong, and digging through fetch options looking for a fix. In reality, the CORS switch lives entirely on the server. There's not much the frontend can do about it.
Here's how it works: when the browser makes a cross-origin request, it attaches an Origin header telling the server where the request came from. If the server is willing to allow that origin, it responds with an Access-Control-Allow-Origin header naming the origin it permits. Only after receiving the response does the browser check that header and decide whether to hand the data over to your JavaScript.
In other words, the request does reach the server, and the server does process it — the browser just withholds the response at the last step and won't let you read it. That's exactly why Postman works and the browser doesn't: Postman never runs this check in the first place, because it isn't a browser.
Simple Requests: No Need to Ask First
Not every cross-origin request gets treated the same way. CORS splits requests into two categories. The first is the "simple request," which qualifies if it meets all of the following:
- The method is
GET,POST, orHEAD - Only a handful of browser-allowed "safe" headers are used, like
Accept,Accept-Language, andContent-Type Content-Typeis limited toapplication/x-www-form-urlencoded,multipart/form-data, ortext/plain
If a request meets these conditions, the browser sends it right away and only checks Access-Control-Allow-Origin once the response comes back. The whole thing is a single round trip.
Preflight Requests: Asking Politely First
But most of the APIs you actually write look more like this:
// custom headers plus JSON body — doesn't qualify as a simple request
fetch('https://api.myapp.com/orders', {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ status: 'shipped' }),
})Using PUT, setting Content-Type to application/json, and adding a custom Authorization header all fall outside what counts as a simple request. In this case, the browser won't just fire off the real request — it first sends a preflight request to check the waters.
The preflight request uses the OPTIONS method and carries two key headers asking the server:
OPTIONS /orders HTTP/1.1
Origin: https://myapp.com
Access-Control-Request-Method: PUT
Access-Control-Request-Headers: Content-Type, Authorization
In plain terms: "I'm about to send a PUT request with these headers — is that okay?" If the server agrees, it responds with the following headers:
Access-Control-Allow-Origin: https://myapp.com
Access-Control-Allow-Methods: GET, POST, PUT, DELETE
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Max-Age: 86400
Only once the browser confirms these headers match up does it actually send the real PUT request. Access-Control-Max-Age tells the browser how long it can cache this result — within that window, the same request doesn't need to ask again, saving a round trip.
The preflight is something the browser handles automatically — you don't need to manually deal with OPTIONS. But the backend does need to remember to respond to OPTIONS requests with the correct headers; if the preflight fails, the real request never even gets sent.
Want to Send Cookies Along? You'll Need One More Header
If your cross-origin request needs to carry cookies or other credentials, the frontend has to add credentials: 'include' to the fetch call:
fetch('https://api.myapp.com/profile', {
credentials: 'include', // send cookies along
})But that alone isn't enough — the server needs to explicitly respond with:
Access-Control-Allow-Origin: https://myapp.com
Access-Control-Allow-Credentials: true
Here's a trap a lot of people fall into: once credentials are involved, Access-Control-Allow-Origin can't be a wildcard * — it has to name an explicit origin. This is a deliberate restriction in the spec: if any origin could grab data using a user's cookies, the whole point of CORS would collapse.
Setting It Up on a Node.js Backend
Take Express as an example. The most bare-bones approach is adding the headers yourself in a middleware:
app.use((req, res, next) => {
res.setHeader('Access-Control-Allow-Origin', 'https://myapp.com')
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE')
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization')
res.setHeader('Access-Control-Allow-Credentials', 'true')
// respond to preflight requests with 204, no need to run business logic
if (req.method === 'OPTIONS') {
return res.sendStatus(204)
}
next()
})In practice, it's far more common to just use a package like cors:
import cors from 'cors'
app.use(
cors({
origin: 'https://myapp.com',
credentials: true,
}),
)The logic is the same — the package just wraps up the header configuration and OPTIONS handling for you.
A Trap I Ran Into: Duplicate Headers Get Blocked Too
Beyond missing or misconfigured headers, there's a less-discussed case that you only really appreciate once you've hit it yourself: Access-Control-Allow-Origin showing up more than once in the same response.
This usually happens when more than one layer of your stack is handling CORS. Say you have an Nginx reverse proxy in front, and Nginx's config adds its own Access-Control-Allow-Origin header. Then the application behind it (say, Express with the cors package) adds one too. Stack those together, and the response the browser actually receives ends up looking like this:
Access-Control-Allow-Origin: https://myapp.com
Access-Control-Allow-Origin: https://myapp.com
Even if both values are identical, the browser still treats it as a failure, and the console throws the same CORS error — usually with a message that spells it out directly: "The 'Access-Control-Allow-Origin' header contains multiple values... but only one is allowed." The spec requires this header to appear exactly once; showing up twice makes the response invalid. The browser won't guess which value to trust — it just blocks the whole thing.
This is especially misleading because if you check any single layer's config in isolation, the header looks completely correct, which sends you looking in the wrong place. What actually works is opening the Network tab and checking the response headers the browser actually received. If you see Access-Control-Allow-Origin duplicated, it usually means more than one layer in your stack is adding it — remove the redundant one and the problem goes away.
How Do You Debug This?
When you run into a CORS error, here's the order to check things in:
- Open the Network tab in your browser's dev tools and check whether there's an
OPTIONSrequest. If there is, look at whether its response has the correctAccess-Control-Allow-*headers. - Check that the origin in
Access-Control-Allow-Originmatches your page's actual origin character-for-character — protocol and port included — and confirm it only appears once, not duplicated by different layers (like a reverse proxy and the application) each adding it separately. - If you're using
credentials: 'include', check whether the server is missingAccess-Control-Allow-Credentials: true, or has mistakenly setAllow-Originto*. - Make sure the server actually responds properly to the
OPTIONSmethod itself, rather than having it blocked by routing rules or requiring authentication.
Trace most CORS issues back far enough, and they turn out to be misconfigured server headers, not frontend code.
Wrapping Up
The core idea behind CORS isn't complicated: browsers block cross-origin responses by default under the Same-Origin Policy, and CORS is how the server uses a set of HTTP headers to explicitly tell the browser which origins, methods, and headers are allowed. Simple requests just get sent, with the response header checked afterward; anything more complex — custom headers, unusual methods — gets a preflight request first. As long as you remember this is a permission system the server controls, the next time you see that red line in the console, you'll know exactly where to look.