A friend once asked me what CORS was. I explained it the way most of us first learn it: you tell your server which domains are allowed to send requests. On a dev machine you allow localhost so your local frontend can talk to the backend. In production you allow only your real frontend domain.
It felt simple, and it mostly worked in my head.
A few weeks later I gave the same explanation in an interview. The interviewer nodded and then asked one short question: is CORS a browser security measure or a server security measure?
I went with server. We are protecting our backend from unknown domains, so it has to be the server doing the guarding. Right?
Wrong. And the gap between what I said and what actually happens is the part worth writing down.
Table of contents
Open Table of contents
Browser or server security?
CORS lives in the browser. The server is not the one enforcing it.
Here is the test that makes it obvious. Take any endpoint that returns a CORS error in the browser, then hit the same endpoint with curl or Postman. It returns the data without complaint. No Origin check, no block, nothing. The server happily answered. The browser was the only thing that ever cared about the origin.
So CORS is a set of rules a browser follows about which cross-origin responses it will let your JavaScript read. It only comes into play when your frontend and backend sit on different origins. Same origin, no CORS at all. A Next.js app that serves its frontend and API routes from one domain never trips over it, because the browser sees same-origin requests and stays out of the way.
How it is implemented?
The browser attaches an Origin header to cross-origin requests, set to the page’s own origin. When the response comes back, the browser looks for an Access-Control-Allow-Origin header. If that header matches the origin, your JavaScript gets to read the response. If it does not match, the request still reached the server and the server still ran it, but the browser throws away the response before your code can touch it.
The server already did the work. The block happens on the way back, inside the browser.
Some requests get an extra step. If the request uses a method other than GET, POST, or HEAD, or carries custom headers, or sends a Content-Type like application/json, the browser does not send it straight away. It first sends a preflight: an OPTIONS request that asks the server, in advance, whether the real request is allowed. Most requests in a real app fall into this category, so preflights are the common case.
Preflight, request and response
Here is a preflight the browser sends before a PUT that carries an auth token:
OPTIONS /users HTTP/1.1
Origin: https://frontend.com
Access-Control-Request-Method: PUT
Access-Control-Request-Headers: Authorization, Content-Type
Read it as the browser checking ahead: I am about to send a PUT from https://frontend.com, and it will carry Authorization and Content-Type headers. Are all of those allowed?
The server answers:
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://frontend.com
Access-Control-Allow-Methods: GET, POST, PUT
Access-Control-Allow-Headers: Authorization, Content-Type
Access-Control-Allow-Credentials: true
Access-Control-Max-Age: 3600
Each header answers part of the question:
Access-Control-Allow-Originsays which origin may read the response. Here, onlyhttps://frontend.com.Access-Control-Allow-Methodslists the methods the server will accept cross-origin.Access-Control-Allow-Headerslists the request headers it will accept. If the real request sends a header not on this list, the browser blocks it.Access-Control-Allow-Credentials: trueallows the browser to expose the response to JavaScript when a cross-origin request includes browser-managed credentials (such as cookies). It cannot be used together withAccess-Control-Allow-Origin: *. The server must specify the exact allowed origin.Access-Control-Max-Ageis how long the browser may cache this preflight result. Set to 3600, the browser skips theOPTIONSround trip for the next hour. Leave it off and you pay a preflight before nearly every request, which is a real latency tax on a chatty frontend.
All of this hinges on one match: the Origin the browser sent and the Access-Control-Allow-Origin it got back have to agree. Everything else is detail layered on top of that one comparison.
There are several other headers. You can check them out in the MDN docs.
So what is CORS actually protecting?
If CORS only runs in the browser, why not copy the request into Postman, change the Origin, and replay it? You can. But from Postman you send your own cookies, or none, so you only ever get your own data back. You never had the victim’s session.
The attack CORS cares about needs the victim’s browser and their live session together.
You are logged into your bank, session cookie sitting in the browser. In another tab you open evil-coupons.com, and its JavaScript runs:
// served from https://evil-coupons.com
fetch("https://bank.com/account", { credentials: "include" })
.then(res => res.json())
.then(data => sendToAttacker(data)); // this line never runs
The browser attaches your bank cookie. Cookies go to the domain they belong to, whichever page made the call, so the request arrives looking exactly like you. The bank sees a valid session and sends back your balance.
Then the browser checks that response for Access-Control-Allow-Origin: https://evil-coupons.com. It is not there, so the browser refuses to hand the body to evil-coupons.com’s code. The .then that would read your balance never runs.
That is the whole job. The browser’s same-origin policy already stops one site from reading another origin’s responses; CORS is just how a server hands a chosen origin a key to that lock. Your real frontend gets a key. evil-coupons.com does not.
CORS is not a CSRF defense
That whole story was about reading. Blocking the read feels like enough until you meet an attack that never needed to read anything.
Same bank tab. This time evil-coupons.com does not try to see your balance, it moves your money:
// served from https://evil-coupons.com
fetch("https://bank.com/transfer", {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: "to=attacker&amount=1000",
});
Same cookie, same valid session. The request reaches the bank, passes its auth checks, and the transfer goes through. CORS then blocks evil-coupons.com from reading the { "success": true } reply, but the attacker never wanted it. The money already moved. That is CSRF, and CORS sits on the wrong side of it: it guards the response, after the request has already done its damage.
This only works because the request skips the preflight. A simple request, a form-encoded body with no custom headers, goes straight to the server. Send Content-Type: application/json instead and the browser preflights first; the check fails for evil-coupons.com and the POST never leaves. An API that only accepts JSON dodges this almost by accident.
The defenses that actually stop it work on the request, before the server acts:
SameSite=LaxorStricton the session cookie, so the browser never attaches it to a cross-site request to begin with. This is the modern default, and it is also what quietly defuses the read attack from the last section. SettingSameSite=Noneturns it off, so reach for that only when you truly need cross-site cookies.- A CSRF token the attacker’s page cannot read or guess.
- Checking the
OriginorRefererheader server-side before accepting a state change.
Two questions, kept apart:
- CORS asks: can JavaScript from another origin read my response?
- CSRF protection asks: should I accept this authenticated request at all?
A site can nail its CORS config and still be wide open to CSRF.
Wrapping up
The request reaches your server either way. CORS only decides whether the browser hands the response back to the JavaScript that asked for it. It is a browser rule about reading responses, sitting on top of the authentication and authorization your server still has to run on every request.