All posts
Security & Best Practices··10 min read

CORS, CSRF, and the Same-Origin Policy, Explained

CORS, CSRF, and the same-origin policy get confused constantly. They solve different problems. A clear, code-first explanation of what each one actually does.

By

On this page

Every few months I review someone's backend and find the same line: Access-Control-Allow-Origin: * next to Access-Control-Allow-Credentials: true. The author was trying to silence a CORS error in the browser console, and they were convinced that loosening CORS would also protect them from CSRF. Both beliefs are wrong, and the combination is one of the most common security holes I see in production.

The confusion is structural. CORS, CSRF, and the same-origin policy all involve cross-origin HTTP and cookies, so they get filed in the same mental folder. They are not the same thing. One is a browser default. One relaxes that default. One is an attack the default never fully prevented. Let me separate them properly, with the code that actually matters.

What an origin is

An origin is the triple of scheme + host + port. Not the path. Not the query string. These are all distinct origins:

https://app.example.com        scheme=https host=app.example.com port=443
http://app.example.com         different scheme
https://api.example.com        different host (subdomain counts)
https://app.example.com:8443   different port

https://app.example.com/login and https://app.example.com/dashboard share an origin — path is irrelevant. A common mistake is assuming example.com and www.example.com are "the same site." They are the same site (registrable domain) but different origins. That distinction matters later, because SameSite cookies operate on site, while CORS operates on origin.

The Same-Origin Policy: the browser's default lockdown

The same-origin policy (SOP) is a browser rule, enforced unilaterally by Chrome, Firefox, and Safari. By default, script running on origin A can send requests to origin B, but it cannot read the response unless B explicitly allows it. It also blocks reading another origin's DOM, localStorage, and IndexedDB.

The part people miss: SOP does not block the request from being sent. A fetch() to another origin still leaves the browser, the server still processes it, and any cookies scoped to that origin still ride along. The browser just hides the response body from the calling script. This is exactly why CSRF works — the damage is done on the server before the response is ever read.

SOP is the baseline. CORS and CSRF are both reactions to it: CORS loosens it on purpose, CSRF abuses the gap it leaves open.

CORS: relaxing the policy, on the server's terms

CORS (Cross-Origin Resource Sharing) is how origin B says "I trust origin A to read my responses." It is opt-in, and the server controls it entirely through response headers. The browser is the enforcer; the server is the policymaker.

Simple requests vs. preflight

The browser splits cross-origin requests into two classes. A simple request (a GET, HEAD, or POST with only "CORS-safelisted" headers and a content type of text/plain, multipart/form-data, or application/x-www-form-urlencoded) goes straight to the server. Anything else — a PUT, a DELETE, a custom header like Authorization or X-CSRF-Token, or Content-Type: application/json — triggers a preflight.

A preflight is an automatic OPTIONS request the browser fires before the real one, asking permission:

OPTIONS /api/orders HTTP/1.1
Origin: https://app.example.com
Access-Control-Request-Method: DELETE
Access-Control-Request-Headers: authorization, x-csrf-token

The server must answer with matching Access-Control-Allow-* headers, or the browser blocks the real request and never sends it:

HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET, POST, PUT, DELETE
Access-Control-Allow-Headers: authorization, x-csrf-token
Access-Control-Allow-Credentials: true
Access-Control-Max-Age: 600

That Access-Control-Max-Age: 600 caches the preflight result for 10 minutes, so you are not paying a round trip on every call. Chrome caps it at 7200 seconds (2 hours); Firefox at 86400. Set it — an uncached preflight on a chatty SPA can double your effective request count.

A correct CORS configuration

Here is a CORS setup I would actually ship, in Express with the cors@2.8.5 middleware. The key decisions: an explicit allowlist (never a reflected origin), credentials enabled deliberately, and a tight method/header list.

import express from "express";
import cors, { type CorsOptions } from "cors";
 
const ALLOWED_ORIGINS = new Set([
  "https://app.example.com",
  "https://admin.example.com",
]);
 
const corsOptions: CorsOptions = {
  origin(origin, callback) {
    // `origin` is undefined for same-origin and non-browser clients (curl, server-to-server).
    if (!origin || ALLOWED_ORIGINS.has(origin)) {
      return callback(null, true);
    }
    return callback(new Error(`Origin ${origin} not allowed by CORS`));
  },
  credentials: true,
  methods: ["GET", "POST", "PUT", "PATCH", "DELETE"],
  allowedHeaders: ["Content-Type", "Authorization", "X-CSRF-Token"],
  maxAge: 600,
};
 
const app = express();
app.use(cors(corsOptions));

When credentials: true is set, the middleware echoes back the specific requesting origin in Access-Control-Allow-Origin (because the spec forbids * with credentials) and adds Access-Control-Allow-Credentials: true. The allowlist Set is what keeps that echo honest.

The two misconfigurations that actually bite

1. Wildcard with credentials. The browser flat-out rejects Access-Control-Allow-Origin: * when the request is credentialed. People then "fix" it by reflecting the Origin header instead — which is worse.

2. Reflecting the Origin header. This is the dangerous one:

// NEVER DO THIS
app.use((req, res, next) => {
  res.header("Access-Control-Allow-Origin", req.headers.origin); // attacker-controlled
  res.header("Access-Control-Allow-Credentials", "true");
  next();
});

Reflecting Origin means you allow every origin, including https://evil.example. Combined with Allow-Credentials: true, a malicious site can now make authenticated requests to your API and read the responses — exfiltrating user data with the victim's own session. The OWASP CORS guidance is blunt about this: validate Origin against an allowlist, never reflect it blindly.

A subtler variant is a sloppy suffix check like origin.endsWith("example.com"), which happily matches https://notexample.com. Match the full origin string.

CSRF: a completely different problem

Here is the pivot people miss. CORS does not protect you from CSRF. They are nearly opposite concerns:

CORSCSRF
What it isA relaxation of SOPAn attack
Who enforcesThe browser, on the server's headersThe defense is yours to build
Concerned withCan script read a cross-origin responseCan an attacker send a state-changing request
CookiesOptional (credentials)The whole mechanism
Stops the request?No, only hides the responseThe defense must reject the request

CSRF (Cross-Site Request Forgery, OWASP's classic A01-adjacent flaw) works because of the gap SOP leaves: the browser sends cookies automatically with cross-origin requests. An attacker's page doesn't need to read your response. It just needs the request to happen.

The classic exploit is a form on the attacker's site:

<form action="https://bank.example.com/transfer" method="POST">
  <input type="hidden" name="to" value="attacker" />
  <input type="hidden" name="amount" value="10000" />
</form>
<script>document.forms[0].submit()</script>

When a logged-in victim loads this page, their browser submits the form to bank.example.com with their session cookie attached. No CORS preflight fires, because an HTML form POST with application/x-www-form-urlencoded is a simple request. The transfer goes through. The attacker never reads the response — they don't care. The money already moved.

Defense 1: SameSite cookies

The single highest-leverage defense, and it's a cookie attribute. SameSite=Lax (the default in Chrome since version 80 and now across all major browsers) tells the browser: do not attach this cookie to cross-site requests, except top-level navigations via safe methods like clicking a link. A cross-site POST from an attacker's form gets no cookie, so the server sees an unauthenticated request and rejects it.

res.cookie("session", token, {
  httpOnly: true,    // not readable by JS — mitigates XSS-based theft
  secure: true,      // HTTPS only
  sameSite: "lax",   // blocks cross-site POST/fetch from sending the cookie
  maxAge: 1000 * 60 * 60 * 24 * 7,
  path: "/",
});

Use sameSite: "strict" if your app has no legitimate cross-site entry points (it breaks "follow a link from email and land logged in"). Use "none" only when you genuinely need third-party cross-site cookies — and "none" requires secure: true or the browser silently drops the cookie.

SameSite=Lax is not a complete defense on its own. It's site-based, so a malicious subdomain under the same registrable domain can still be "same-site." And Lax still permits top-level GET navigations to carry the cookie — which is exactly why state-changing operations must never live behind a GET.

Defense 2: a synchronizer token

For defense in depth, especially on cookie-based session apps, add a CSRF token. The server generates a random token, ties it to the session, and requires it back in a header or hidden field on every state-changing request. The attacker's site cannot read the token (SOP blocks that) and cannot guess it, so it cannot forge a valid request.

import crypto from "node:crypto";
 
function issueCsrfToken(sessionId: string, secret: string): string {
  const random = crypto.randomBytes(32).toString("hex");
  const hmac = crypto
    .createHmac("sha256", secret)
    .update(`${sessionId}.${random}`)
    .digest("hex");
  return `${random}.${hmac}`; // signed double-submit token
}
 
function verifyCsrfToken(token: string, sessionId: string, secret: string): boolean {
  const [random, sig] = token.split(".");
  if (!random || !sig) return false;
  const expected = crypto
    .createHmac("sha256", secret)
    .update(`${sessionId}.${random}`)
    .digest("hex");
  // constant-time comparison defeats timing attacks
  return crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected));
}
 
// Middleware on every POST/PUT/PATCH/DELETE
app.use((req, res, next) => {
  if (["GET", "HEAD", "OPTIONS"].includes(req.method)) return next();
  const token = req.get("X-CSRF-Token") ?? "";
  if (!verifyCsrfToken(token, req.session.id, process.env.CSRF_SECRET!)) {
    return res.status(403).json({ error: "Invalid CSRF token" });
  }
  next();
});

crypto.timingSafeEqual matters — a naive === comparison leaks how many leading bytes matched through response timing, which is enough to brute-force a token byte by byte.

Defense 3: check Origin / Referer

As a backstop, validate the Origin header (or fall back to Referer) on state-changing requests against your allowlist. Modern browsers send Origin on POST even for same-origin requests, and an attacker cannot forge it from JavaScript. It's cheap insurance, not a primary control.

Why a token-in-header SPA is less CSRF-prone

If your SPA authenticates with a token it stores and sends as Authorization: Bearer <token> (or a custom header), CSRF mostly evaporates. The reason is mechanical: CSRF rides ambient credentials the browser attaches automatically — cookies. A Bearer token in a header is not ambient. The attacker's page would have to read your token and set the header itself, and SOP blocks it from reading your storage. The custom header also forces a CORS preflight, which the attacker cannot satisfy.

The tradeoff, which I covered in the JWT-vs-sessions post: a header token means giving up HttpOnly, so you take on more XSS exposure in exchange for less CSRF exposure. Cookie sessions are the reverse. Pick your poison deliberately — there is no free option.

A decision checklist

When you set up a new API, walk this list:

  • Origins explicitly allowlisted. Never reflect req.headers.origin. Never pair * with credentials. Match the full origin string, not a suffix.
  • Access-Control-Max-Age set (300–600s) so preflights aren't on every call.
  • Cookies are HttpOnly, Secure, SameSite=Lax (or Strict if no cross-site entry). SameSite=None only with a real cross-site need.
  • State changes never behind GET. Lax cookies still ride top-level GET navigations.
  • CSRF token on every mutating request for cookie-based sessions, compared with timingSafeEqual.
  • Origin/Referer checked as a backstop on mutations.
  • Header-token SPA? You've largely sidestepped CSRF — now your priority shifts to XSS and token storage.

The mental model to keep: SOP is the default. CORS opens a specific door on purpose. CSRF sneaks through a door SOP never closed. Tightening CORS does nothing for CSRF, and loosening it carelessly hands attackers your users' data. Treat them as three separate problems, because they are.

Further reading

  • MDN Web Docs — Cross-Origin Resource Sharing (CORS) and the Same-Origin Policy: developer.mozilla.org
  • OWASP Cross-Site Request Forgery Prevention Cheat Sheet: owasp.org
  • RFC 6265bis — Cookies, including the SameSite attribute
  • MDN — Set-Cookie and the SameSite directive: developer.mozilla.org