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.
On this page
- What an origin is
- The Same-Origin Policy: the browser's default lockdown
- CORS: relaxing the policy, on the server's terms
- Simple requests vs. preflight
- A correct CORS configuration
- The two misconfigurations that actually bite
- CSRF: a completely different problem
- Defense 1: SameSite cookies
- Defense 2: a synchronizer token
- Defense 3: check Origin / Referer
- Why a token-in-header SPA is less CSRF-prone
- A decision checklist
- Further reading
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 porthttps://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-tokenThe 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: 600That 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:
| CORS | CSRF | |
|---|---|---|
| What it is | A relaxation of SOP | An attack |
| Who enforces | The browser, on the server's headers | The defense is yours to build |
| Concerned with | Can script read a cross-origin response | Can an attacker send a state-changing request |
| Cookies | Optional (credentials) | The whole mechanism |
| Stops the request? | No, only hides the response | The 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-Ageset (300–600s) so preflights aren't on every call. - Cookies are
HttpOnly,Secure,SameSite=Lax(orStrictif no cross-site entry).SameSite=Noneonly with a real cross-site need. - State changes never behind
GET.Laxcookies still ride top-levelGETnavigations. - CSRF token on every mutating request for cookie-based sessions, compared with
timingSafeEqual. -
Origin/Refererchecked 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
SameSiteattribute - MDN —
Set-Cookieand theSameSitedirective: developer.mozilla.org