CORS: What is it protecting?

sanyamserver.online · 2026-06-28 · by Long Nguyen ·generic
CORS is a browser security mechanism, not a server one. What the Origin header and preflight checks actually do, what CORS protects against, and why it is not CSRF protection.

CORS: What is it protecting?

CORS: What is it protecting?

27 Jun, 2026

|

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?
  • How it is implemented?
  • Preflight, request and response
  • So what is CORS actually protecting?
  • CORS is not a CSRF defense
  • Wrapping up
  • 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-Origin` says which origin may read the response. Here, only `https://frontend.com`.
  • `Access-Control-Allow-Methods` lists the methods the server will accept cross-origin.
  • `Access-Control-Allow-Headers` lists 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: true` allows 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 with `Access-Control-Allow-Origin: `. The server must specify the exact allowed origin.
  • `Access-Control-Max-Age` is how long the browser may cache this preflight result. Set to 3600, the browser skips the `OPTIONS` round 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=Lax` or `Strict` on 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. Setting `SameSite=None` turns 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 `Origin` or `Referer` header 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.

    ---

    Back to top

  • cors
  • browser
  • security
  • backend

Share this post:

Share this post on Whatsapp Share this post on Facebook Share this post on X Share this post on Telegram Share this post on Pinterest Share this post via email

---

Get new posts by email

New backend engineering articles, straight to your inbox. No spam.

you@example.comSubscribe

Thanks! Check your inbox to confirm your subscription.

Next Post Nginx as a Reverse Proxy

Comments (0)

No comments yet