Web Security (OWASP Top 10:2025) Module 2 – A01:2025 Broken Access Control
CSRF: tokens, SameSite and Fetch Metadata
How another site gets a browser to send your session cookie, and the layers that stop it: SameSite, signed tokens, and the Sec-Fetch-Site and Origin headers.
What you will learn
- Explain why a cookie-authenticated request can be forged from another site, and which requests a browser sends without a preflight
- Choose a SameSite value for a session cookie and name the requests it still lets through
- Choose a synchroniser token or a signed double-submit token for an app, and bind the signed one to the session
- Add a Sec-Fetch-Site and Origin check as a second layer, and handle a missing or null value
- Test the sign-in form and the WebSocket handshake for the same weakness
Before you start
What you’ll learn · Before you start
What you will learn
- Explain why a cookie-authenticated request can be forged from another site, and which requests a browser sends without a preflight
- Choose a SameSite value for a session cookie and name the requests it still lets through
- Choose a synchroniser token or a signed double-submit token for an app, and bind the signed one to the session
- Add a Sec-Fetch-Site and Origin check as a second layer, and handle a missing or null value
- Test the sign-in form and the WebSocket handshake for the same weakness
Before you start
On this page
A page on another site can make Asha’s browser send a POST to your app, and the browser adds her session cookie to it. Of the five requests the first program below builds from another site, two reach a transfer route that trusts the cookie, and each of them moves 2,000.
That weakness is cross-site request forgery (CSRF): A request that another site makes a signed-in user's browser send to your app. The browser attaches the cookie, so a server that trusts the cookie alone acts on it. The defences prove that your own page sent the request.: a server acts on a request because its cookie is valid, although the person it belongs to never meant to send it. You will see which requests need no permission from your server, add three layers that stop them, a SameSite cookie, a token that another site cannot read and a check of the headers a browser adds, and test the two places that are easy to forget, the sign-in form and the WebSocket handshake.
A cookie rides along on a request that another site starts
Asha is signed in to app.example, and her browser holds its session cookie. She opens a page on other.example, which
holds a form whose action is the address of your /transfer route. When the form is submitted, her browser sends the
POST and attaches her cookie, because the cookie rule looks at where a request is going and not at which page started
it. The route sees a valid session and moves money. Step through it:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- Asha is signed in. Her browser holds the session cookie for app.example, set when she signed in. The cookie rule picks cookies by the address a request goes to, so the browser sends this one with every request to app.example that SameSite allows.
- She opens another page. She follows a link to a page on other.example. The page holds a form whose action is the address of your /transfer route, written by the owner of other.example and not by anyone on your team.
- The form is submitted. The page submits the form as soon as it loads. The browser sends a POST to app.example and adds Asha's cookie, because the request goes to app.example. Nothing asks app.example first, because a form post is a simple request.
- The weak route acts. The route sees a valid session and moves 2,000. A cookie shows who is signed in, not who wrote the request, so the route cannot tell this request from one that Asha chose to send.
- The answer goes nowhere. The same-origin policy keeps the page on other.example from reading the response, but the page never needed to read it. The transfer has already happened, and blocking a read is not blocking a request.
- The fixed route refuses. With the layers of this lesson, Sec-Fetch-Site says cross-site and the form carries no token, which a page on another site cannot read. The route answers 403 and moves nothing.
A cookie shows who is signed in, not who wrote the request, and that is the whole weakness. MITRE files it as CWE-352, the 2025 OWASP Top 10 maps it to A01 Broken Access Control, and it ranks third in the 2025 CWE Top 25. The page on the other site never sees the response, because the same-origin policy keeps it from reading one. That is no comfort: the transfer has already happened, and blocking a read is not blocking a request.
Practise on code you own
Aim forged requests only at programs that you run, such as the ones in this lesson. Aiming them at anyone else’s site needs the owner’s written permission, which the first lesson of this track explains.
Which requests can another site make at all? A browser sends some of them without asking your server first. MDN calls
them simple request: A cross-origin request that a browser sends without asking first: GET, HEAD or POST with only safelisted headers and, for a POST, a form content type. The page cannot read the answer, but the server has already acted., and the Fetch standard defines them through its safelists: a GET,
HEAD or POST that sets no header beyond a short safe list and, for a POST, has a form content type,
application/x-www-form-urlencoded, multipart/form-data or text/plain. Anything else, such as a PUT, a JSON content type or a custom header, makes the
browser send a preflight: The OPTIONS request a browser sends before a cross-origin request that is not simple. It asks which methods and headers the server allows, and if the answer gives no permission the real request is never sent. first, an OPTIONS request that asks your server whether it allows
the real one. If the answer carries no permission, the real request is never sent. The program is a model of those
rules, and it sends nothing over a network. It lists five requests that a page on other.example can make, whether each
needs a preflight, and what a route that trusts the cookie does with it:
// Which requests a page on other.example can make Asha's browser send to app.example, and what a handler that trusts
// the session cookie does with them. needsPreflight follows the Fetch standard's rule for the cases shown: GET, HEAD
// and POST with only safelisted headers and a form content type go straight out; for anything else the browser asks
// app.example first (a preflight), and app.example's answer carries no CORS headers, so the request is never sent.
// The session cookie here is SameSite=None, so the browser attaches it to every request: the next section adds SameSite.
const FORM_TYPES = new Set(['application/x-www-form-urlencoded', 'multipart/form-data', 'text/plain']);
const SAFE_HEADERS = new Set(['accept', 'accept-language', 'content-language']);
function needsPreflight({ method, headers }) {
if (!['GET', 'HEAD', 'POST'].includes(method)) return true;
for (const [name, value] of Object.entries(headers)) {
const n = name.toLowerCase();
if (n === 'content-type') {
if (!FORM_TYPES.has(value.split(';')[0].trim().toLowerCase())) return true;
} else if (!SAFE_HEADERS.has(n)) return true;
}
return false;
}
const withCommas = (n) => String(n).replace(/\B(?=(\d{3})+(?!\d))/g, ',');
let balance = 10000;
function weakTransfer(req) {
if (req.method !== 'POST') return '405, nothing changes';
if (req.cookies.session !== 'asha-session') return '401, not signed in';
balance -= 2000;
return `200, 2,000 moved, balance ${withCommas(balance)}`;
}
const fromOtherSite = [
{ label: 'form POST, urlencoded', method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' } },
{ label: 'fetch() POST, text/plain', method: 'POST', headers: { 'Content-Type': 'text/plain' } },
{ label: 'fetch() POST, JSON', method: 'POST', headers: { 'Content-Type': 'application/json' } },
{ label: 'fetch() POST, X-CSRF-Token', method: 'POST', headers: { 'Content-Type': 'text/plain', 'X-CSRF-Token': 'x' } },
{ label: 'fetch() PUT', method: 'PUT', headers: {} },
];
console.log(`${'request from other.example'.padEnd(29)}${'preflight'.padEnd(11)}weak handler`);
for (const r of fromOtherSite) {
const asked = needsPreflight(r);
const outcome = asked ? 'never sent: the preflight was not allowed' : weakTransfer({ ...r, cookies: { session: 'asha-session' } });
console.log(`${r.label.padEnd(29)}${(asked ? 'yes' : 'no').padEnd(11)}${outcome}`);
} Output
request from other.example preflight weak handler form POST, urlencoded no 200, 2,000 moved, balance 8,000 fetch() POST, text/plain no 200, 2,000 moved, balance 6,000 fetch() POST, JSON yes never sent: the preflight was not allowed fetch() POST, X-CSRF-Token yes never sent: the preflight was not allowed fetch() PUT yes never sent: the preflight was not allowed
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node forged_post.mjs
Runs on this device, in your browser. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs.
Your run, in this browser
Two of the five reach the route, the form post and a fetch() with the content type text/plain, and each moves
2,000, so the balance falls from 10,000 to 6,000. The JSON post, the post with a custom X-CSRF-Token header and the
PUT each need a preflight, and the answer of app.example carries no CORS headers, so the browser never sends them.
Counting on missing CORS headers
“We send no CORS headers, so other sites cannot call us” is true of reading and false of sending. A form post leaves
the other site with no permission asked, and the text/plain row shows fetch() doing the same. A route that wants
JSON must reject every other content type, and the layers below do not depend on CORS at all.
Checkpoint
Check: Why a cookie rides along on a forged request
SameSite stops requests from other sites, but a sibling host is the same site
The SameSite cookie attribute is the oldest layer. Its three values decide when the browser attaches the cookie to a
request that another site started: Strict never, Lax only on a top-level navigation that uses a safe method such as
GET, and None always, which needs Secure. This model applies those rules, as RFC 6265bis and MDN describe them, to
five requests to app.example. A request is same-site when the page that starts it has the same scheme and registrable
domain, which the model takes to be the last two labels of the host:
# Which requests to https://app.example carry its session cookie, under each SameSite setting. A model of the rules
# of RFC 6265bis: a request is same-site when the page that starts it has the same scheme and registrable domain as
# app.example; Strict cookies go only on same-site requests, Lax ones also on cross-site top-level navigations with a
# safe method (GET), None ones on every request. A cookie without the attribute behaves as Lax in Chrome and Edge and
# as None in Firefox and Safari, so the last two columns show both. The registrable domain here is the last two labels.
TARGET = ("https", "app.example")
def site(scheme, host):
return scheme, ".".join(host.split(".")[-2:])
def sent(samesite, request):
same_site = site(*request["from"]) == site(*TARGET)
if samesite == "Strict":
return same_site
if samesite == "Lax":
return same_site or (request["top_level"] and request["method"] == "GET")
return True # None: every request
requests = [
{"label": "app.example: its own form, POST", "from": ("https", "app.example"), "method": "POST", "top_level": True},
{"label": "blog.app.example: form, POST", "from": ("https", "blog.app.example"), "method": "POST", "top_level": True},
{"label": "other.example: a link, GET", "from": ("https", "other.example"), "method": "GET", "top_level": True},
{"label": "other.example: a form, POST", "from": ("https", "other.example"), "method": "POST", "top_level": True},
{"label": "other.example: an image, GET", "from": ("https", "other.example"), "method": "GET", "top_level": False},
]
columns = [("Strict", "Strict"), ("Lax", "Lax"), ("None", "None"), ("unset, Lax", "Lax"), ("unset, None", "None")]
print((f"{'request to app.example':<34}" + "".join(f"{name:<13}" for name, _ in columns)).rstrip())
for r in requests:
print((f"{r['label']:<34}" + "".join(f"{'sent' if sent(rule, r) else '-':<13}" for _, rule in columns)).rstrip()) Output
request to app.example Strict Lax None unset, Lax unset, None app.example: its own form, POST sent sent sent sent sent blog.app.example: form, POST sent sent sent sent sent other.example: a link, GET - sent sent sent sent other.example: a form, POST - - sent - sent other.example: an image, GET - - sent - sent
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 samesite_model.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
Read the table by columns. Under Strict only requests from the site itself carry the cookie, so even a plain link
from another site arrives signed out. Under Lax the link works, because it is a top-level GET, while the form post
and the image still go without the cookie. None sends everything, which is why it belongs on a cookie only when a
page must work inside other sites’ frames. The last two columns are a cookie with no attribute, where browsers
disagree: MDN’s compatibility data says that Chrome 80 and Edge 86 apply Lax to it, that Firefox does so only when a
preference is switched on, and that Safari leaves it alone. Set the attribute on every session cookie.
Move the sibling to another site
Press Edit and change blog.app.example to blog.other.example in the second request, in its label and in its
host, then run the model again. That form post is no longer same-site, so the Strict and Lax columns now show a
dash for it: the cookie went with the sibling’s post only because the two hosts share a registrable domain.
Two gaps matter more than the defaults:
- A sibling is the same site. The second row is
blog.app.example. UnderStrictthe cookie still goes with its form post, because SameSite is scoped to the registrable domain and not to the origin, as OWASP’s guide stresses. Any host under your domain that you do not control is a place to start requests that carry the cookie: a user-content subdomain, a partner’s, or one whose DNS record points at a service that nobody owns any more. - Lax lets a top-level GET through. The link row carries the cookie, so a route that changes state on GET is open.
MDN adds that a framework’s method override can make a GET act like a POST, and that a cookie under the browser’s
default
Laxstill goes with a cross-site POST during the first two minutes after it was set.
Key idea
SameSite cuts the requests of other sites, not those of other hosts of your own site: a sibling subdomain, a top-level GET and a browser with another default all get past it, so it is a layer and not the defence.
Checkpoint
Check: What SameSite stops and what it leaves open
A token that another site cannot read shows the request came from your page
The layers that do not depend on the cookie ask the page for something only a page of your own origin can do: read a value. Your page can read a token that your server put in it, and a form on another site cannot, because the same-origin policy keeps another origin’s script from reading your pages and your cookies. A request that carries the token therefore came from a page that could read it. OWASP’s advice starts with your framework: use its built-in CSRF protection where it has one, and write your own only when it has none.
A synchroniser token: A random CSRF token that the server stores in the user's session and puts in each page. The request sends it back in a field or a header, and the server compares it with the stored copy. is a random value that the server stores in the user’s
session and puts in each page, in a hidden field or in a meta tag that the page’s script reads. The request sends it
back in the field or in a header, and the server compares it with the stored copy. It never goes in a URL, where
history, logs and Referer headers would keep it, and OWASP advises against carrying it in a cookie. A new token for
every request is stronger and breaks the Back button, so most apps keep one for the session.
A server that keeps no record to store a token in can sign one instead. A
signed double-submit token: A stateless CSRF token: a MAC over the session id and a random nonce, followed by the nonce, set in a cookie the page can read and sent back in a header. The server recomputes the MAC and compares it. token is a MAC over the session id and a random nonce,
followed by the nonce. The server sets it in a cookie that the page’s script can read, so that cookie has no
HttpOnly, and the page copies it into a header on every POST. The server never trusts the cookie as it is: it
recomputes the MAC from the session id of this request and the nonce in the token, and compares the two in constant
time. Binding the MAC to the session is what matters, because OWASP notes that a signed token without that binding
gives little protection. Step through one token, from its issue to the check or the refusal:
The drawing shows the whole scene; its 8 steps are listed below.
Every step as text
- Issue the token. When Asha signs in, the server draws a nonce of 32 random bytes and signs it together with her session id. The MAC is HMAC-SHA-256 under a server key, and the token is the MAC, a dot and the nonce, 129 characters in all.
- Set it in a cookie. The token goes into a cookie that the page's script can read, so the cookie has no HttpOnly. It keeps Secure and SameSite=Strict, and its name starts with __Host-, so no sibling site can replace it.
- The page copies it. On each POST the page reads the cookie and sends its value in a header. A form on another site cannot read the cookie, so it cannot send the header, and that difference is the whole idea.
- Check the shape. The server first checks for two parts of 64 lowercase hex characters, and refuses anything else before it computes anything. A malformed token never reaches the MAC code.
- Recompute the MAC. It takes the nonce from the token and the session id from the request's own session cookie, and computes the MAC itself. A token made for Ravi's session fails here, because the session id is different.
- Compare in constant time. The two MACs are compared with a function that does not stop at the first different byte. A match means the server itself signed this nonce for this session.
- The request is accepted. A matching token means the request was sent by script that could read the cookie, which is a page of your own origin. The transfer runs.
- A wrong token is refused. A wrong shape or a different MAC ends at 403, and the server logs the refusal without writing the token down. One failure path keeps the route simple to test.
The program below checks the same six cases against both designs, and adds the design OWASP discourages, the naive double submit, which only checks that the cookie and the header are equal. The key is a made-up lab key, and the nonce is fixed so that the MAC repeats from run to run:
# Two ways to check a CSRF token, on the same six cases. The synchroniser token lives in the server's session record and
# is compared with what the request sends back. The signed double-submit token is an HMAC of the session id and a random
# value; the server keeps nothing and recomputes the HMAC. KEY is a made-up lab key: a real one comes from a secret store.
import hashlib
import hmac
import secrets
KEY = b"lab-key-not-a-real-secret"
def issue_sync(session):
"""Synchroniser token: random, stored in the session, sent to the page."""
session["csrf"] = secrets.token_urlsafe(32)
return session["csrf"]
def check_sync(session, sent):
return isinstance(sent, str) and hmac.compare_digest(sent.encode(), session.get("csrf", "").encode())
def mac(session_id, nonce):
message = f"{len(session_id)}!{session_id}!{len(nonce)}!{nonce}".encode()
return hmac.new(KEY, message, hashlib.sha256).hexdigest()
def issue_signed(session_id, nonce=None):
"""Signed double-submit token: mac.nonce, set in a cookie the page can read and sent back in a header."""
nonce = nonce or secrets.token_hex(32)
return f"{mac(session_id, nonce)}.{nonce}"
def check_signed(session_id, sent):
parts = sent.split(".") if isinstance(sent, str) else []
if len(parts) != 2 or not all(len(p) == 64 and set(p) <= set("0123456789abcdef") for p in parts):
return False
return hmac.compare_digest(mac(session_id, parts[1]), parts[0])
def check_naive(cookie, header):
"""Naive double submit: the cookie and the header are equal. Nothing ties them to the session."""
return bool(cookie) and cookie == header
def changed(token):
"""The same token with its last character replaced by a different one."""
return token[:-1] + ("0" if token[-1] != "0" else "1")
asha = {"id": "sess-asha-1"}
ravi = {"id": "sess-ravi-9"}
sync_token = issue_sync(asha)
signed = issue_signed(asha["id"], nonce="a1" * 32)
ravis_signed = issue_signed(ravi["id"], nonce="b2" * 32)
print("signed token:", len(signed), "characters: a 64-character MAC, a dot, a 64-character nonce")
print("the MAC for the nonce 'a1' repeated 32 times:", signed.split(".")[0])
print(f"{'case':<34}{'synchroniser':<14}signed")
cases = [
("Asha's own token", check_sync(asha, sync_token), check_signed(asha["id"], signed)),
("no token sent", check_sync(asha, None), check_signed(asha["id"], None)),
("Ravi's token on Asha's session", check_sync(asha, issue_sync(ravi)), check_signed(asha["id"], ravis_signed)),
("one character changed", check_sync(asha, changed(sync_token)), check_signed(asha["id"], changed(signed))),
("an empty token", check_sync(asha, ""), check_signed(asha["id"], "")),
("a value nobody issued", check_sync(asha, "planted"), check_signed(asha["id"], "planted.planted")),
]
for name, a, b in cases:
print(f"{name:<34}{str(a):<14}{b}")
print("naive double submit, a sibling site plants 'planted' as cookie and header:", check_naive("planted", "planted")) Output
signed token: 129 characters: a 64-character MAC, a dot, a 64-character nonce the MAC for the nonce 'a1' repeated 32 times: 944b746e89e234058b0ee2756f6c7cb4a864f6f4dd43283c6bb2cc388bb13a31 case synchroniser signed Asha's own token True True no token sent False False Ravi's token on Asha's session False False one character changed False False an empty token False False a value nobody issued False False naive double submit, a sibling site plants 'planted' as cookie and header: True
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 csrf_tokens.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
Both designs refuse every case except Asha’s own token. The last line is the reason for the signing. A page on a sibling subdomain can set a cookie for the parent domain, so it can plant a value in the cookie and send the same value in the header, and the naive design accepts the pair. The signed design rejects it, because nobody but the server can compute a MAC for Asha’s session, and Ravi’s token fails on her session for the same reason.
Comparing tokens with ==
A comparison that stops at the first different character can leak how much of a guess was right. Use the language’s
constant-time function, hmac.compare_digest in Python or crypto.timingSafeEqual in Node.js, and give the second
one buffers of equal length: it throws when they differ.
Checkpoint
Check: Synchroniser and signed double-submit tokens
Sec-Fetch-Site and Origin tell the server who started the request
Browsers add Fetch Metadata: Request headers whose names start with Sec-Fetch- that a browser adds to say where a request came from and how it was made, such as Sec-Fetch-Site. Page script cannot set or change them. headers that say where a request came from, and a page’s
script cannot change them. Sec-Fetch-Site has four values: same-origin, same-site, cross-site and none, for a
request the user started by typing an address or using a bookmark. MDN’s compatibility data lists Chrome 76, Firefox 90
and Safari 16.4 as the first versions to send it, and the W3C document that defines these headers is still a Working
Draft. The Origin header names the origin that started the request. A browser sends it on every POST, PUT, PATCH and
DELETE, on cross-origin requests made with CORS and on WebSocket handshakes.
A check that uses them is short. Safe methods pass. For the rest, same-origin and none pass and every other value
is refused, same-site included, unless you trust every sibling host. When Sec-Fetch-Site is missing, as it is in
browsers older than the versions above, the check falls back to Origin, which must equal your own origin exactly.
A request with neither header comes from a tool that is not a browser. Go 1.25 added this kind of check to its
standard library as net/http.CrossOriginProtection: GET, HEAD and OPTIONS always pass, Sec-Fetch-Site decides when it
is present, and otherwise the host of Origin is compared with the Host header, not the whole origin. A request with
neither header passes that check, just as it passes the first layer of the program below. The program sends nine
requests to the weak handler of the first section and to a fixed one, which applies the header check first and the
signed token second:
// POST /transfer in two versions, and the same nine requests sent to both, all from Asha's browser with her valid
// session cookie. The weak handler trusts the cookie. The fixed one has two layers for every unsafe request: a check of
// what the browser says about who started it (Sec-Fetch-Site, else Origin), then a signed double-submit token bound to
// the session. KEY is a made-up lab key; a real one comes from a secret store.
import { createHmac, timingSafeEqual } from 'node:crypto';
const ORIGIN = 'https://app.example';
const KEY = 'lab-key-not-a-real-secret';
const SESSION = 'sess-asha-1';
const SAFE = new Set(['GET', 'HEAD', 'OPTIONS']);
const mac = (session, nonce) => createHmac('sha256', KEY).update(`${session.length}!${session}!${nonce.length}!${nonce}`).digest('hex');
const issue = (session, nonce) => `${mac(session, nonce)}.${nonce}`;
function tokenValid(sent, session) {
if (typeof sent !== 'string' || !/^[0-9a-f]{64}\.[0-9a-f]{64}$/.test(sent)) return false;
const [theirs, nonce] = sent.split('.');
const ours = Buffer.from(mac(session, nonce));
return timingSafeEqual(Buffer.from(theirs), ours);
}
// Layer 1: a reason to refuse, or null to go on. Only a browser adds these headers, so no header proves nothing.
function headersLayer(req) {
if (SAFE.has(req.method)) return null;
const site = req.headers['sec-fetch-site'];
if (site !== undefined) return site === 'same-origin' || site === 'none' ? null : `Sec-Fetch-Site is ${site}`;
const origin = req.headers.origin;
if (origin === undefined || origin === 'null') return null; // no evidence either way: the token decides
return origin === ORIGIN ? null : `Origin is ${origin}`;
}
function weakTransfer(req) {
return req.method === 'POST' ? '200 moved' : '405';
}
function fixedTransfer(req) {
if (req.method !== 'POST') return '405';
const why = headersLayer(req);
if (why) return `403 headers: ${why}`;
if (!tokenValid(req.headers['x-csrf-token'], SESSION)) return '403 token: none that fits this session';
return '200 moved';
}
const hers = issue(SESSION, 'a1'.repeat(32));
const ravis = issue('sess-ravi-9', 'b2'.repeat(32));
const post = (headers) => ({ method: 'POST', headers });
const requests = [
['her own page, fetch() with her token', post({ 'sec-fetch-site': 'same-origin', origin: ORIGIN, 'x-csrf-token': hers })],
['form on other.example', post({ 'sec-fetch-site': 'cross-site', origin: 'https://other.example' })],
['form on blog.app.example', post({ 'sec-fetch-site': 'same-site', origin: 'https://blog.app.example' })],
['older browser, form on other.example', post({ origin: 'https://other.example' })],
['Origin: null, with her token', post({ origin: 'null', 'x-csrf-token': hers })],
['Origin: null, no token', post({ origin: 'null' })],
['curl with her cookie, no token', post({})],
["her own page, but Ravi's token", post({ 'sec-fetch-site': 'same-origin', origin: ORIGIN, 'x-csrf-token': ravis })],
['link on other.example (GET)', { method: 'GET', headers: { 'sec-fetch-site': 'cross-site' } }],
];
console.log(`${'request'.padEnd(40)}${'weak handler'.padEnd(14)}fixed handler`);
for (const [label, req] of requests) console.log(`${label.padEnd(40)}${weakTransfer(req).padEnd(14)}${fixedTransfer(req)}`); Output
request weak handler fixed handler her own page, fetch() with her token 200 moved 200 moved form on other.example 200 moved 403 headers: Sec-Fetch-Site is cross-site form on blog.app.example 200 moved 403 headers: Sec-Fetch-Site is same-site older browser, form on other.example 200 moved 403 headers: Origin is https://other.example Origin: null, with her token 200 moved 200 moved Origin: null, no token 200 moved 403 token: none that fits this session curl with her cookie, no token 200 moved 403 token: none that fits this session her own page, but Ravi's token 200 moved 403 token: none that fits this session link on other.example (GET) 405 405
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node transfer_gate.mjs
Read the fixed column from the top. Asha’s own fetch() passes both layers. The form on other.example and the form on
the sibling blog.app.example stop at the first layer, with the reason the browser gave, and the older browser’s form
stops there too, on Origin. Two rows show what a null value is worth. Origin: null with her token goes through and
without a token is refused, because null is no evidence either way: the Fetch standard sends it on a site’s own form
post when the page sets Referrer-Policy: no-referrer, and any site can create a context that sends it, so OWASP
advises never to put it on a list of allowed origins. The curl row has no browser headers, passes the first layer and
meets the token. Last, Ravi’s token on Asha’s session is refused, which is
the binding at work.
Headers describe browsers, not callers
Sec-Fetch-Site and Origin say what an honest browser saw. A script outside a browser writes any header it likes, as
the lesson on origins showed, so these checks keep other
sites in the user’s browser from using the user’s cookie and never prove who the caller is.
Which layers an app needs depends on four things about it. Answer them for your own app in the table below:
Answer the questions above: the table then marks what fits your case and says why the rest does not.
| Layer | A form on another site | A sibling subdomain | Older browsers | Server state |
|---|---|---|---|---|
| SameSite Strict or Lax Decides by: the site that started the request | Good Strict sends no cookie on a cross-site request; Lax sends none on a cross-site POST | Poor blog.app.example is the same site, so the cookie went with its form post | Mixed Set it explicitly: Lax is the default only in Chrome 80+ and Edge 86+ | Good nothing to store |
| Synchroniser token Decides by: a token kept in the session | Good a form on another site cannot read the token, so it cannot send it back | Good a sibling is another origin, so its page cannot read your token either | Good plain HTML and a lookup on the server: no browser feature is needed | Poor one token per session, stored and looked up on every POST |
| Signed double-submit Decides by: a MAC bound to the session id | Good a form cannot read the cookie, so it cannot send the header | Good the planted pair failed: it was not signed for this session | Good works in every browser that sends cookies and headers | Good stateless: one server key to keep safe and rotate |
| Sec-Fetch-Site check Decides by: what the browser says started it | Good Sec-Fetch-Site was cross-site, and the fixed route refused it | Mixed same-site is yours to decide; the lab refuses it, as Go does | Poor sent only since Chrome 76, Firefox 90 and Safari 16.4: needs a fallback | Good nothing to store |
| Origin check Decides by: the origin that started it | Good Origin names other.example on a POST, and the older browser was refused | Good Origin names the sibling host, so it fails an exact comparison | Mixed it can be null or missing, and then the token has to decide | Good one configured origin string |
| Custom header Decides by: a header that forces a preflight | Good a form cannot add a custom header, and a fetch() with one needs a preflight | Mixed a CORS rule that lets a sibling in also lets its header in | Good works wherever CORS works | Good nothing to store: the route checks that the header is there |
How the answers decide
- “Does your registrable domain include hosts you do not control, such as user content or forgotten subdomains?” Yes, or I am not sure: adds a caveat to SameSite Strict or Lax, Sec-Fetch-Site check and Custom header. A sibling host is the same site, so SameSite cookies ride along on its requests, and a rule that lets a sibling in lets its header in. An exact Origin check and a token tell it apart from your page.
- “Must the app protect people whose browsers are older than 2023?” Yes, any browser: adds a caveat to Sec-Fetch-Site check and Origin check. Older browsers send no Sec-Fetch-Site, and may send Origin as null or not at all. Keep a token as the layer that always decides.
- “Does the server keep a session record for every signed-in user?” No, sessions are signed tokens: rules out Synchroniser token. A synchroniser token has to be stored for each session. With sessions that are signed tokens, sign the CSRF token instead.
- “Are the routes that change state a JSON API called by your own script, with no HTML forms?” No, some routes take form posts: rules out Custom header. A form cannot add a header, so a route that takes form posts cannot require one. Use a token in a hidden field.
In short: use the framework’s token protection where there is one, keep SameSite on the session cookie, add the
Sec-Fetch-Site and Origin checks as a second layer, and let the token decide whenever a header is missing or null.
Key idea
Refuse what the browser says came from another site, let the token decide the rest, and never read a missing or null header as proof that a request is yours.
Checkpoint
Check: Fetch Metadata and Origin as a second layer
The sign-in form and the WebSocket handshake need the same care
Two requests escape most CSRF work because they do not look like a state change. Sign-in changes who the browser is. In login CSRF: A forged sign-in: a form on another site posts credentials of an account its owner controls, so the visitor's browser is signed in to it. A token on the sign-in form, issued before any session exists, prevents it., a form on another site posts credentials of an account that its owner controls, and the visitor’s browser is signed in to that account. Whatever the person then saves, a card, an address, a search, lands in an account that the other site’s owner can read. The fix is a token on the sign-in form, issued during a pre-session because no real session exists yet. After a successful sign-in the pre-session is destroyed and a new session id is issued, a step OWASP asks for to prevent session fixation as well.
A WebSocket starts as an ordinary HTTP GET that asks to upgrade. The browser attaches the site’s cookies by the same
rules as for any request, so a cookie that SameSite lets through goes along even when a page on another site opened the
socket, and the browser adds Origin. WebSockets are not covered by the same-origin policy or by CORS, so nothing asks
your server first. RFC 6455 leaves the decision to the server and says a server that does not check
Origin will accept connections from anywhere. OWASP calls the result cross-site WebSocket hijacking: a page on
another site gets a live, signed-in socket. Step through one handshake:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- The handshake is a GET. A script on other.example opens wss://app.example/live. The browser starts with an HTTP GET that asks to upgrade. It adds the header Origin with the address of the page's origin, and Asha's cookies for app.example as far as SameSite lets them go.
- The weak route checks the cookie. The cookie came along and is valid, so the route answers 101 and the socket is open. A WebSocket is not covered by the same-origin policy, so the browser did not ask first, and the page on other.example can now send and read messages as Asha.
- The fixed route reads Origin. Before it upgrades, the fixed route compares Origin with its list, which holds https://app.example. The value https://other.example is not on the list, and the comparison is exact, so a lookalike host would not be on it either.
- It refuses the handshake. The route answers 403 and the socket never opens. The page on other.example gets an error, and the cookie was never used for anything.
- Asha's own page passes. A page that app.example served sends Origin with the address https://app.example, which is on the list. The route goes on to check the session and upgrades.
- A tool is not a browser. A script outside a browser can write any Origin it likes, so this check protects the person using a browser, not the route from callers who hold credentials. Those callers still meet the session or token check.
The fix is a check, before the upgrade, that Origin is in a list of your own origins, compared exactly. That is ASVS
5.0.0 requirement 4.4.2. Because a tool outside a browser can send any Origin, a handshake that needs more than a
cookie should also carry a token that the signed-in page got over HTTPS, which ASVS 4.4.3 and 4.4.4 describe and
OWASP’s WebSocket cheat sheet repeats. This program holds both fixes, with a weak version of each beside it:
// Two places that need the same care as the transfer route. Part 1: the sign-in form. Part 2: the WebSocket handshake.
// Tokens come from the operating system's random source, and only results that do not depend on them are printed. knownLogin stands in for a check against a stored password hash. Part 1 shows login
// CSRF: a form on another site signs the visitor in to an account that the other site's owner controls (mallory).
import { randomBytes, timingSafeEqual } from 'node:crypto';
const ORIGIN = 'https://app.example';
const ACCOUNTS = { asha: 'lab-only-password-1', mallory: 'lab-only-password-2' };
const knownLogin = (user, password) => Object.hasOwn(ACCOUNTS, user) && ACCOUNTS[user] === password;
const newId = () => randomBytes(24).toString('base64url');
function same(a, b) {
if (typeof a !== 'string' || typeof b !== 'string') return false;
const x = Buffer.from(a);
const y = Buffer.from(b);
return x.length === y.length && timingSafeEqual(x, y); // a length is no secret; timingSafeEqual throws on unequal ones
}
// --- Part 1: sign-in. The weak route signs in whoever posts valid credentials. The fixed route also wants the token
// of a pre-session, created when the form was served, and replaces it with a new session id after the sign-in.
const preSessions = new Map(); // id -> { token }
function serveLoginForm() {
const id = newId();
preSessions.set(id, { token: newId() });
return { cookie: id, token: preSessions.get(id).token };
}
function weakLogin(req) {
return knownLogin(req.user, req.password) ? { status: 200, session: newId() } : { status: 401 };
}
function fixedLogin(req) {
const pre = preSessions.get(req.preCookie);
if (!pre || !same(pre.token, req.token)) return { status: 403 };
if (!knownLogin(req.user, req.password)) return { status: 401 };
preSessions.delete(req.preCookie); // the pre-session ends here
return { status: 200, session: newId() };
}
const asha = { user: 'asha', password: 'lab-only-password-1' };
const mallory = { user: 'mallory', password: 'lab-only-password-2' };
const logins = [
['Asha, from her own sign-in page', () => { const f = serveLoginForm(); return { ...asha, preCookie: f.cookie, token: f.token }; }],
['a form on another site, no token', () => ({ ...mallory, preCookie: serveLoginForm().cookie })],
['a form on another site, no cookie', () => ({ ...mallory })],
];
console.log(`${'sign-in request'.padEnd(36)}${'weak'.padEnd(8)}fixed`);
for (const [label, build] of logins) {
const req = build();
console.log(`${label.padEnd(36)}${String(weakLogin(req).status).padEnd(8)}${fixedLogin(req).status}`);
}
const form = serveLoginForm();
const signedIn = fixedLogin({ ...asha, preCookie: form.cookie, token: form.token });
console.log(`pre-session gone after the sign-in: ${!preSessions.has(form.cookie)}; new session id: ${signedIn.session !== form.cookie}`);
// --- Part 2: the WebSocket handshake. A browser sends the site's cookies with every handshake, whatever page opened
// the socket, and always adds Origin. The fixed route compares Origin with a list, exactly, before it upgrades.
const ALLOWED = new Set([ORIGIN]);
const weakHandshake = (req) => (req.cookie === 'valid' ? '101 upgraded' : '401');
function fixedHandshake(req) {
if (req.origin === undefined || !ALLOWED.has(req.origin)) return '403 origin not on the list';
return req.cookie === 'valid' ? '101 upgraded' : '401';
}
const origins = [
ORIGIN,
'https://other.example',
'https://app.example.other.example',
'http://app.example',
'https://app.example:8443',
'null',
undefined,
];
console.log(`\n${'Origin of the handshake'.padEnd(36)}${'weak'.padEnd(16)}fixed`);
for (const origin of origins) {
const req = { origin, cookie: 'valid' };
console.log(`${(origin ?? '(no Origin header)').padEnd(36)}${weakHandshake(req).padEnd(16)}${fixedHandshake(req)}`);
} Output
sign-in request weak fixed Asha, from her own sign-in page 200 200 a form on another site, no token 200 403 a form on another site, no cookie 200 403 pre-session gone after the sign-in: true; new session id: true Origin of the handshake weak fixed https://app.example 101 upgraded 101 upgraded https://other.example 101 upgraded 403 origin not on the list https://app.example.other.example 101 upgraded 403 origin not on the list http://app.example 101 upgraded 403 origin not on the list https://app.example:8443 101 upgraded 403 origin not on the list null 101 upgraded 403 origin not on the list (no Origin header) 101 upgraded 403 origin not on the list
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node login_and_socket.mjs
In the first table the fixed route signs Asha in from her own page and refuses the two forms from another site, the
first because it carries no token and the second because it carries no pre-session at all. After a successful sign-in
the pre-session is gone and the session id is new. The second table sends seven Origin values to both routes. The
weak route upgrades every one of them. The fixed route upgrades only the exact https://app.example: a lookalike
host, plain HTTP, another port, null and a missing header all fail the comparison.
Two limits remain. Cross-site scripting on your own origin can read the token and send any request, so OWASP says it
defeats every CSRF defence, and output encoding and a content security policy stay necessary. And a state change must
never use GET, because a GET carries a Lax cookie, passes the header check as a safe method, and is fetched by
prefetchers and link checkers that nobody watches.
Checkpoint
Check: Sign-in forms and WebSocket handshakes
Practise: a transfer route that checks both layers
Write the decision function of the fixed route: the method, Sec-Fetch-Site, Origin and the token, in that order.
The sample tests include the sibling site, Origin: null, a token that is one character off and a prefix of the right
token:
Exercise · Medium · JavaScript, Python
Refuse a transfer that another site started
transferDecision(req, session) (Python: transfer_decision) decides whether a POST /transfer from a signed-in user may run. req holds the method and a headers object whose names are in lower case; session holds the user's CSRF token (csrfToken, in Python csrf_token). It returns { allowed, reason }. Apply these rules in this order, and stop at the first one that refuses:
- A method other than
POSTis refused with the reason"method". - When
sec-fetch-siteis present,same-originandnonego on to rule 4 and every other value is refused with"site". In both casesoriginis not looked at. - When
sec-fetch-siteis absent, anoriginthat is present must equalORIGINexactly, or be the text"null", which says nothing either way; any otheroriginis refused with"origin". With neither header the request may come from a tool that is not a browser, so it goes on. - The
x-csrf-tokenheader must equal the session's token, character by character. A missing token, a token of another length and a wrong token are refused with"token". Write the comparison as a loop that does not stop at the first difference. - Otherwise return
{ allowed: true, reason: "ok" }.
The starter checks only the method.
JavaScript · Starter code · transfer.mjs
export const ORIGIN = 'https://app.example';
/** Whether POST /transfer may run: { allowed, reason }. `req` is { method, headers }, `session` is { csrfToken }. */
export function transferDecision(req, session) {
if (req.method !== 'POST') return { allowed: false, reason: 'method' };
return { allowed: true, reason: 'ok' };
} The sample tests · transfer.test.mjs
import { test, assert } from 'mysmartcopilot:test';
import { ORIGIN, transferDecision } from './transfer.mjs';
const session = { csrfToken: 'k7Qm2xPz9wLd' };
const post = (headers = {}) => ({ method: 'POST', headers: { 'x-csrf-token': session.csrfToken, ...headers } });
const refused = (reason) => ({ allowed: false, reason });
const ok = { allowed: true, reason: 'ok' };
test('only POST is a transfer', () => {
assert.deepEqual(transferDecision({ method: 'GET', headers: {} }, session), refused('method'));
assert.deepEqual(transferDecision({ method: 'PUT', headers: { 'sec-fetch-site': 'cross-site' } }, session), refused('method'));
});
test('same-origin and none go on to the token', () => {
assert.deepEqual(transferDecision(post({ 'sec-fetch-site': 'same-origin' }), session), ok);
assert.deepEqual(transferDecision(post({ 'sec-fetch-site': 'none' }), session), ok);
});
test('every other Sec-Fetch-Site is refused, even with the right token', () => {
assert.deepEqual(transferDecision(post({ 'sec-fetch-site': 'cross-site' }), session), refused('site'));
assert.deepEqual(transferDecision(post({ 'sec-fetch-site': 'same-site' }), session), refused('site'));
assert.deepEqual(transferDecision(post({ 'sec-fetch-site': 'something-new' }), session), refused('site'));
});
test('Sec-Fetch-Site decides when it is there, and Origin is not looked at', () => {
assert.deepEqual(transferDecision(post({ 'sec-fetch-site': 'same-origin', origin: 'https://other.example' }), session), ok);
assert.deepEqual(transferDecision(post({ 'sec-fetch-site': 'cross-site', origin: ORIGIN }), session), refused('site'));
});
test('without Sec-Fetch-Site, Origin must be exactly ours', () => {
assert.deepEqual(transferDecision(post({ origin: ORIGIN }), session), ok);
assert.deepEqual(transferDecision(post({ origin: 'https://other.example' }), session), refused('origin'));
assert.deepEqual(transferDecision(post({ origin: 'https://app.example.other.example' }), session), refused('origin'));
assert.deepEqual(transferDecision(post({ origin: 'http://app.example' }), session), refused('origin'));
});
test('Origin: null says nothing, so the token decides', () => {
assert.deepEqual(transferDecision(post({ origin: 'null' }), session), ok);
assert.deepEqual(transferDecision({ method: 'POST', headers: { origin: 'null' } }, session), refused('token'));
});
test('with neither header the token still decides', () => {
assert.deepEqual(transferDecision(post(), session), ok);
assert.deepEqual(transferDecision({ method: 'POST', headers: {} }, session), refused('token'));
});
test('the token must match exactly', () => {
const wrong = (token) => transferDecision(post({ 'sec-fetch-site': 'same-origin', 'x-csrf-token': token }), session);
assert.deepEqual(wrong('k7Qm2xPz9wLe'), refused('token'), 'one character differs');
assert.deepEqual(wrong('k7Qm2xPz9w'), refused('token'), 'a prefix of the token');
assert.deepEqual(wrong('k7Qm2xPz9wLdd'), refused('token'), 'the token and one more character');
assert.deepEqual(wrong(''), refused('token'));
assert.deepEqual(wrong(undefined), refused('token'));
});
test('the reasons come in the order of the rules', () => {
const bad = { method: 'POST', headers: { 'sec-fetch-site': 'cross-site', 'x-csrf-token': 'nope' } };
assert.deepEqual(transferDecision(bad, session), refused('site'));
assert.deepEqual(transferDecision({ method: 'GET', headers: bad.headers }, session), refused('method'));
}); Python · Starter code · transfer.py
ORIGIN = "https://app.example"
def transfer_decision(req, session):
"""Whether POST /transfer may run: {"allowed": bool, "reason": str}. req has "method" and "headers"."""
if req["method"] != "POST":
return {"allowed": False, "reason": "method"}
return {"allowed": True, "reason": "ok"} The sample tests · test_transfer.py
from transfer import ORIGIN, transfer_decision
session = {"csrf_token": "k7Qm2xPz9wLd"}
OK = {"allowed": True, "reason": "ok"}
def post(**headers):
base = {"x-csrf-token": session["csrf_token"]}
base.update({name.replace("_", "-"): value for name, value in headers.items()})
return {"method": "POST", "headers": base}
def refused(reason):
return {"allowed": False, "reason": reason}
def test_only_post_is_a_transfer():
assert transfer_decision({"method": "GET", "headers": {}}, session) == refused("method")
assert transfer_decision({"method": "PUT", "headers": {"sec-fetch-site": "cross-site"}}, session) == refused("method")
def test_same_origin_and_none_go_on_to_the_token():
assert transfer_decision(post(sec_fetch_site="same-origin"), session) == OK
assert transfer_decision(post(sec_fetch_site="none"), session) == OK
def test_every_other_sec_fetch_site_is_refused():
assert transfer_decision(post(sec_fetch_site="cross-site"), session) == refused("site")
assert transfer_decision(post(sec_fetch_site="same-site"), session) == refused("site")
assert transfer_decision(post(sec_fetch_site="something-new"), session) == refused("site")
def test_sec_fetch_site_decides_when_it_is_there():
assert transfer_decision(post(sec_fetch_site="same-origin", origin="https://other.example"), session) == OK
assert transfer_decision(post(sec_fetch_site="cross-site", origin=ORIGIN), session) == refused("site")
def test_without_sec_fetch_site_origin_must_be_exactly_ours():
assert transfer_decision(post(origin=ORIGIN), session) == OK
assert transfer_decision(post(origin="https://other.example"), session) == refused("origin")
assert transfer_decision(post(origin="https://app.example.other.example"), session) == refused("origin")
assert transfer_decision(post(origin="http://app.example"), session) == refused("origin")
def test_origin_null_says_nothing_so_the_token_decides():
assert transfer_decision(post(origin="null"), session) == OK
assert transfer_decision({"method": "POST", "headers": {"origin": "null"}}, session) == refused("token")
def test_with_neither_header_the_token_still_decides():
assert transfer_decision(post(), session) == OK
assert transfer_decision({"method": "POST", "headers": {}}, session) == refused("token")
def test_the_token_must_match_exactly():
def wrong(token):
headers = {"sec-fetch-site": "same-origin"}
if token is not None:
headers["x-csrf-token"] = token
return transfer_decision({"method": "POST", "headers": headers}, session)
assert wrong("k7Qm2xPz9wLe") == refused("token"), "one character differs"
assert wrong("k7Qm2xPz9w") == refused("token"), "a prefix of the token"
assert wrong("k7Qm2xPz9wLdd") == refused("token"), "the token and one more character"
assert wrong("") == refused("token")
assert wrong(None) == refused("token")
def test_the_reasons_come_in_the_order_of_the_rules():
headers = {"sec-fetch-site": "cross-site", "x-csrf-token": "nope"}
assert transfer_decision({"method": "POST", "headers": headers}, session) == refused("site")
assert transfer_decision({"method": "GET", "headers": headers}, session) == refused("method") A hint
Write the rules as a chain of early returns, in the order of the prompt. For rule 2, read sec-fetch-site first; only when it is undefined (Python: missing) look at origin. For rule 4, return false at once when either token is not a string or the lengths differ, then OR together the differences of every pair of characters and compare the result with 0.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
The sample tests run on this device, in your browser (QuickJS, Pyodide): nothing is sent to mysmartcopilot.com. The first run of each language downloads it: JavaScript (about 0.6 MB) or Python (about 13.5 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.
The questions that follow go through the five ideas of the lesson, and they begin with the one that your answers show to be weakest:
Practice that adapts to you
Practise this lesson
16 questions about 5 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
Warm-up: what is CSRF, and how do you prevent it?
Cross-site request forgery makes a signed-in user’s browser send a request to your app from another site. The browser
attaches the session cookie because the request goes to your app, and a server that trusts the cookie acts on it; MITRE
files it as CWE-352. Prevent it in layers. Use the framework’s CSRF protection, which adds a token that only your own
pages can read to every request that changes state. Set SameSite on the session cookie, Lax or Strict, knowing it
does not cover sibling subdomains. Check Sec-Fetch-Site, and Origin where it is missing, and let the token decide
when a header is missing or null. Never change state on GET, and protect the sign-in form and any WebSocket handshake
too.
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Fresher to mid
A reviewer says: "Our API accepts only JSON and our CORS rules allow only our own origin, so CSRF cannot happen here." Sessions are cookies. How do you test that claim, and what do you add whatever the result?
-
Mid to staff
Your SaaS serves the app at app.example and lets each customer publish pages, with their own scripts, at name.app.example. The session cookie is SameSite=Strict and CSRF protection compares a cookie with a header. What is wrong, and what do you change?
Flashcards · 8 cards
Recall it, then check
Say the answer to yourself before you look: recalling it is what makes it stick.
-
What is CSRF, and why does the browser send the cookie?
A page on another site makes the signed-in user's browser send a request to your app. The browser adds the session cookie because of where the request goes, not which page started it, so a server that trusts the cookie alone acts on a request its owner never meant (CWE-352, mapped to A01 Broken Access Control). The same-origin policy blocks the read, not the request.
-
Which cross-site requests reach your server without a preflight?
Simple requests: a GET, HEAD or POST that sets no header beyond a short safe list and, for a POST, uses a form content type (text/plain, multipart/form-data or application/x-www-form-urlencoded). A JSON body, a custom header or a PUT starts a preflight. So "we send no CORS headers" protects reads, not form posts or text/plain fetches.
-
What does SameSite not protect you from?
A sibling subdomain (SameSite is scoped to the registrable domain, so a user-content or forgotten subdomain can start requests that carry the cookie); a top-level GET under Lax, so never change state on GET; and browsers with another default for a cookie with no attribute. Set the attribute on every session cookie.
-
How does a synchroniser token work?
The server stores a random value in the user's session and puts it in each page, in a hidden field or a meta tag the page's script reads. The request sends it back in a field or header and the server compares it with the stored copy. Another site cannot read it. It never goes in a URL, and one per session is usual.
-
How does a signed double-submit token work, and why is the naive double submit weak?
The token is a MAC over the session id and a random nonce, followed by the nonce, in a cookie the page's script can read (no HttpOnly) and copied into a header. The server recomputes the MAC from this request's session id and the nonce and compares in constant time. Naive double submit only checks that cookie and header are equal, and a sibling subdomain can plant the same pair.
-
How should a CSRF token be compared, and what must you remember in Node?
With a constant-time function: hmac.compare_digest in Python, crypto.timingSafeEqual in Node. The Node function throws when the buffers differ in length, so give it buffers of equal length. A plain comparison returns at the first character that differs, so its running time can reveal how much of a guess was correct.
-
What header check refuses cross-site requests, and what does it not prove?
Safe methods pass. For the rest, only the Sec-Fetch-Site values same-origin and none are accepted, and every other value is refused (same-site too, unless you trust every sibling host). If it is missing, Origin must equal your own origin exactly. A missing or null value is no evidence, so the token decides. The headers describe what a browser saw, never who the caller is.
-
Which two requests escape most CSRF work, and what fixes each?
The sign-in form, where login CSRF signs the visitor into an account the other site controls, needs a token from a pre-session that is destroyed, with a new session id, after sign-in. The WebSocket handshake is a GET that carries cookies and an Origin nothing checks (cross-site WebSocket hijacking): check Origin against your own exact origins before the upgrade (ASVS 5.0.0 4.4.2) and add a token.
Key takeaways
- A browser attaches a cookie because of where a request goes, not which page started it, so a server that acts on the cookie alone acts on forged requests; a blocked read is not a blocked request.
- Simple requests, form posts and some
fetch()calls, reach your server without a preflight, so missing CORS headers are no defence. SameSiteremoves the cookie from requests of other sites, but a sibling subdomain, a top-level GET and browsers with other defaults get past it.- A token that only your page can read, a synchroniser token or a signed double-submit token bound to the session, decides; naive double submit does not, because a sibling can plant the pair.
Sec-Fetch-SiteandOriginrefuse what a browser says is cross-site, a missing or null value is no evidence, and the sign-in form and the WebSocket handshake need the same checks.
Terms in this lesson
- cross-site request forgery (CSRF)
- A request that another site makes a signed-in user's browser send to your app. The browser attaches the cookie, so a server that trusts the cookie alone acts on it. The defences prove that your own page sent the request.
- Fetch Metadata
- Request headers whose names start with Sec-Fetch- that a browser adds to say where a request came from and how it was made, such as Sec-Fetch-Site. Page script cannot set or change them.
- login CSRF
- A forged sign-in: a form on another site posts credentials of an account its owner controls, so the visitor's browser is signed in to it. A token on the sign-in form, issued before any session exists, prevents it.
- preflight
- The OPTIONS request a browser sends before a cross-origin request that is not simple. It asks which methods and headers the server allows, and if the answer gives no permission the real request is never sent.
- signed double-submit token
- A stateless CSRF token: a MAC over the session id and a random nonce, followed by the nonce, set in a cookie the page can read and sent back in a header. The server recomputes the MAC and compares it.
- simple request
- A cross-origin request that a browser sends without asking first: GET, HEAD or POST with only safelisted headers and, for a POST, a form content type. The page cannot read the answer, but the server has already acted.
- synchroniser token
- A random CSRF token that the server stores in the user's session and puts in each page. The request sends it back in a field or a header, and the server compares it with the stored copy.
References
- A01:2025 Broken Access Control (mapped CWEs) (OWASP Foundation)
- Cross-Site Request Forgery Prevention Cheat Sheet (OWASP Foundation)
- Cross-site request forgery (CSRF) (MDN Web Docs (Mozilla))
- Sec-Fetch-Site header (MDN Web Docs (Mozilla))
- Fetch Metadata Request Headers (W3C)
- Fetch Standard: the Origin header and CORS-safelisted requests (WHATWG)
- net/http: CrossOriginProtection (Go 1.25) (The Go Authors)
- Cookies: HTTP State Management Mechanism (RFC 6265bis, SameSite) (IETF (HTTP Working Group))
- Set-Cookie header: the SameSite attribute (MDN Web Docs (Mozilla))
- CWE-352: Cross-Site Request Forgery (CSRF) (MITRE)
- ASVS 5.0.0, V3 Web Frontend Security (3.3.2, 3.5.1, 3.5.2 and 3.5.3) (OWASP Foundation)
- ASVS 5.0.0, V4 API and Web Service (4.4.2, 4.4.3 and 4.4.4, WebSocket) (OWASP Foundation)
- WebSocket Security Cheat Sheet (OWASP Foundation)
- RFC 6455: The WebSocket Protocol (IETF (RFC Editor))
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress