Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

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.

  • Intermediate
  • 30 minutes
  • Examples run with Node.js 24.21.0, quickjs 0.32.0, Python 3.14.8, Pyodide 314.0.7 and sql.js 1.14.2
  • By MySmartCoPilot
Free lesson, Pro answers: 30 minutes
Free: the whole lesson. With a pass: the model answers to its 2 interview questions.

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.

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:

Interactive · message by message A form on another site reaches the transfer route Step through one forged request, from the page that holds the form to the route that trusts the cookie, then see where the fixed route stops it.
A form on another site reaches the transfer route, step 6 of 6. Messages so far: Asha's browser to other.example: GET /offer; Asha's browser to app.example: POST /transfer + cookie; app.example to Asha's browser: 200 moved.Asha's browserhas the app.example cookieother.examplea site you do not runapp.examplerefuses: 403GET /offerPOST /transfer + cookie200 moved

The drawing shows the whole scene; its 6 steps are listed below.

Every step as text
  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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:

Five requests from another site, and a route that trusts the cookie JavaScript · forged/forged_post.mjs
// 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

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

  1. Question 1 of 2 Why does Asha's browser attach her app.example cookie to a form that a page on other.example submits to app.example?
    Show the answer: Question 1 of 2

    Answer: The cookie rule picks cookies by where the request is going, not by which page started it

    The browser decides which cookies to send from the destination of the request and the cookie's own attributes. Who started the request matters only when SameSite is set, which is the next section.

  2. Question 2 of 2 In forged_post.mjs, which requests from other.example reach the transfer route? Choose every one.

    Choose every answer that is right.

    Show the answer: Question 2 of 2

    Answer:

    • The fetch() POST with Content-Type text/plain
    • The form post with a urlencoded body

    Only the two simple requests reach the route, so the balance falls from 10,000 to 6,000. The other three are stopped at the preflight, which has nothing to do with the cookie.

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 carry the cookie under each SameSite setting Python · samesite/samesite_model.py
# 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

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. Under Strict the 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 Lax still 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

  1. Question 1 of 2 In samesite_model.py the form post from blog.app.example carries the cookie even under Strict. Why?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: Both hosts have the registrable domain app.example, so the browser counts the request as same-site

    Same-site means the same scheme and registrable domain. A host you do not control under your domain can therefore start requests with your cookie, which is why a token or an Origin check is still needed.

  2. Question 2 of 2 A route changes a user's email address when it receives GET /settings/email?set=new%40example.test. The session cookie is SameSite=Lax. What is true?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: A link on another site can still trigger it, because Lax sends the cookie on top-level GET navigations

    Lax allows the cookie on a top-level GET, so a GET must never change state. Moving the change to a POST also lets the token and header layers apply.

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:

Interactive · stage by stage A signed token, from issue to check Step through one token as the server issues it, then as it comes back on a POST that is checked and on one that is refused.
A signed token, from issue to check, step 8 of 8. Showing Issue the token, Set it in a cookie, The page copies it, Right shape?, Recompute the MAC, Same MAC?, Run the transfer, Answer 403. In focus: Right shape?, Same MAC?, Answer 403.wrong shapedifferent MACIssue the tokenMAC(session, nonce) . nonceSet it in a cookiereadable by the pageThe page copies itX-CSRF-Token on a POSTRight shape?two 64-character hex partsRecompute the MACthis session and the nonceSame MAC?compared in constant timeRun the transferfrom a page of your ownAnswer 403log it without the token

The drawing shows the whole scene; its 8 steps are listed below.

Every step as text
  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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.
  7. 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.
  8. 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:

A synchroniser token and a signed double-submit token, on six cases Python · tokens/csrf_tokens.py
# 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

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

  1. Question 1 of 2 A teammate signs the CSRF token with an HMAC of a random value alone, without the session id in the message. What does a sibling site gain?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: A token it got for its own session verifies on Asha's session as well, so it can plant it

    OWASP notes that signing alone gives little protection. The session id in the MAC is what turns a valid token into a token for this session only.

  2. Question 2 of 2 In csrf_tokens.py, why does the naive double submit accept the planted pair while the signed design rejects it?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: The naive check compares the cookie with the header, and a planter sets both; the signed check needs a MAC that only the server can make for this session

    The naive design trusts that the cookie came from the server. A sibling subdomain can write cookies for the parent domain, so that trust is misplaced. Signing and binding restore it.

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:

Nine requests to a weak handler and to one with two layers JavaScript · gate/transfer_gate.mjs
// 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:

Interactive · answer and compare Which layers does your app need? Answer four questions about your app. The table marks the layers that fit and says why the others need a partner.
Does your registrable domain include hosts you do not control, such as user content or forgotten subdomains?
Must the app protect people whose browsers are older than 2023?
Does the server keep a session record for every signed-in user?
Are the routes that change state a JSON API called by your own script, with no HTML forms?

Answer the questions above: the table then marks what fits your case and says why the rest does not.

Six CSRF layers against the four things that decide which ones an app needs
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

  1. Question 1 of 2 A POST arrives with Origin null, no Sec-Fetch-Site and Asha's valid token. What does the lesson's header layer do with it, and why?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: It goes on to the token check, because null is no evidence either way and the token decides

    A missing or null header is treated as unknown. The signed token, which a page on another site cannot read, makes the decision for those requests.

  2. Question 2 of 2 A POST arrives with a valid session cookie, no Sec-Fetch-Site, no Origin and no token. What does the lesson's gate answer?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: 403 from the token layer, because the header layer has no evidence and the token is required for every unsafe request

    The header layer only refuses what a browser says came from elsewhere. Everything else depends on the token, so a request with no token cannot succeed.

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:

Interactive · message by message A WebSocket handshake from another site Step through one handshake that a page on another site starts, the weak route that upgrades it and the fixed route that reads Origin first.
A WebSocket handshake from another site, step 6 of 6. Messages so far: Asha's browser to app.example/live: GET /live Origin: other; app.example/live to Asha's browser: 101 Switching Protocols; app.example/live to Asha's browser: 403 Forbidden; Asha's browser to app.example/live: GET /live Origin: app.Asha's browserpage from other.exampleapp.example/liveon the list: upgradeGET /live Origin: other101 Switching Protocols403 ForbiddenGET /live Origin: app

The drawing shows the whole scene; its 6 steps are listed below.

Every step as text
  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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:

A sign-in form with a pre-session token, and a handshake that reads Origin JavaScript · edges/login_and_socket.mjs
// 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

  1. Question 1 of 2 Why does the fixed sign-in route ask for a token before anyone is signed in?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: Without it, a form on another site can sign the visitor in to an account its owner controls

    Login CSRF changes which account the browser uses. After a successful sign-in the pre-session ends and a new session id is issued.

  2. Question 2 of 2 Which handshakes does the fixed route in login_and_socket.mjs refuse? Choose every one.

    Choose every answer that is right.

    Show the answer: Question 2 of 2

    Answer:

    • Origin null, and no Origin header at all
    • Origin http://app.example
    • Origin https://app.example.other.example
    • Origin https://app.example:8443

    An exact comparison with a list of full origins refuses everything that is not on it. A suffix or prefix test would let the lookalike host through.

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:

  1. A method other than POST is refused with the reason "method".
  2. When sec-fetch-site is present, same-origin and none go on to rule 4 and every other value is refused with "site". In both cases origin is not looked at.
  3. When sec-fetch-site is absent, an origin that is present must equal ORIGIN exactly, or be the text "null", which says nothing either way; any other origin is refused with "origin". With neither header the request may come from a tool that is not a browser, so it goes on.
  4. The x-csrf-token header 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.
  5. 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.

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.

HMAC Generator Recompute the lab token's MAC: the key lab-key-not-a-real-secret as text, and the message 11!sess-asha-1!64! followed by a1 repeated 32 times.

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.

  1. Question 1 of 16 Which request does a browser send without a preflight?

    Choose one answer.

    Show the answer: Question 1 of 16

    Answer: A POST with Content-Type application/x-www-form-urlencoded

    A simple request uses GET, HEAD or POST, sets only safelisted headers and, for a POST, one of three form content types. Everything else is preflighted.

  2. Question 2 of 16 In forged_post.mjs the balance starts at 10,000 and each transfer that reaches the route moves 2,000. What is the balance after the five requests from other.example?

    Type a number.

    A hint

    Count the requests that need no preflight.

    Show the answer: Question 2 of 16

    Answer: 6000

    Two requests are simple, the form post and the text/plain fetch(), so 10,000 - 2 x 2,000 = 6,000. The other three never leave the browser.

  3. Question 3 of 16 A teammate says "our API takes JSON, so other sites cannot forge requests". Which fact makes you check before you agree?

    Choose one answer.

    Show the answer: Question 3 of 16

    Answer: A cross-site fetch() with Content-Type text/plain is a simple request, so a route that parses the body whatever its type can be reached

    JSON helps only when the server insists on it. OWASP lists rejecting simple content types as one defence and keeps tokens as the layer that always works.

  4. Question 4 of 16 What does SameSite=Strict do?

    Choose one answer.

    Show the answer: Question 4 of 16

    Answer: The browser never attaches the cookie to a request that another site started

    SameSite decides whether a cookie goes with requests that another site started. HttpOnly and Secure close other ways and are independent of it.

  5. Question 5 of 16 Under SameSite=Lax in the model, which request from other.example carries the cookie?

    Choose one answer.

    Show the answer: Question 5 of 16

    Answer: A link that the user follows, a top-level GET

    The link row is the difference between Strict and Lax. It keeps signed-in users signed in when they arrive from another site, and it is why a GET must never change state.

  6. Question 6 of 16 A service serves customer pages from customer.app.example and the app from app.example, with SameSite=Strict on the session cookie. Which statement is right?

    Choose one answer.

    Show the answer: Question 6 of 16

    Answer: A customer's page can still start requests that carry the cookie, so add a token and an Origin check, and serve customer content from another registrable domain

    Anything under your registrable domain that you do not control can start same-site requests. Keep user content on a domain of its own, and let tokens and Origin checks decide for the rest.

  7. Question 7 of 16 Where should a synchroniser token travel?

    Choose one answer.

    Show the answer: Question 7 of 16

    Answer: In a hidden form field or a request header

    The token proves something only when the page has to do work to send it back. Anything the browser sends by itself proves nothing.

  8. Question 8 of 16 Which line compares the submitted token with the expected one correctly?

    Read the code, then choose one answer.

    import hmac
    
    def token_ok(submitted: str, expected: str) -> bool:
        return ...
    Show the answer: Question 8 of 16

    Answer: hmac.compare_digest(submitted.encode(), expected.encode())

    Compare secrets with the language's constant-time function. In Node.js that is crypto.timingSafeEqual, which needs two buffers of equal length.

  9. Question 9 of 16 An app keeps no server-side record of sessions, which are signed cookies. Which CSRF token fits?

    Choose one answer.

    Show the answer: Question 9 of 16

    Answer: A signed double-submit token whose MAC covers the session id and a nonce

    Without storage, sign the token and bind it to the session. The session cookie's own signature proves who is signed in, which is a different question.

  10. Question 10 of 16 Put the steps of the signed-token check in order.

    Give each item its position, from 1 (first).

    Show the answer: Question 10 of 16

    Answer:

    1. Check that the token is two parts of 64 lowercase hex characters
    2. Take the nonce from the token and the session id from the request
    3. Recompute the MAC
    4. Compare the two MACs in constant time
    5. Run the transfer, or answer 403

    The shape check comes first so that a malformed token never reaches the MAC code. The comparison is the last step before the decision.

  11. Question 11 of 16 Which Sec-Fetch-Site value does a browser send for a request that the user started by typing an address?

    Choose one answer.

    Show the answer: Question 11 of 16

    Answer: none

    The four values are same-origin, same-site, cross-site and none. The gate lets same-origin and none through and refuses the other two.

  12. Question 12 of 16 A POST from blog.app.example arrives with Sec-Fetch-Site same-site and a valid token for the session. What does the lesson's gate answer?

    Choose one answer.

    Show the answer: Question 12 of 16

    Answer: 403 from the header layer, because same-site is not trusted

    A sibling host is yours to trust or not. The lab does not, as Go's CrossOriginProtection does not, so the request is refused by the first layer.

  13. Question 13 of 16 A request arrives with Sec-Fetch-Site same-origin from a script that is not a browser. Why can the server not read the header as proof of who sent it?

    Choose one answer.

    Show the answer: Question 13 of 16

    Answer: Only honest browsers fill the header in from the real situation; any other client can write any value

    The headers keep other sites in the user's browser from using the user's cookie. The session or token check decides what a caller may do.

  14. Question 14 of 16 What does login CSRF do?

    Choose one answer.

    Show the answer: Question 14 of 16

    Answer: It signs the visitor's browser in to an account that another site's owner controls

    The browser's identity changes, and the visitor may not notice. A token on the sign-in form, issued during a pre-session, prevents it.

  15. Question 15 of 16 What does the fixed sign-in route do with the pre-session after a successful sign-in?

    Choose one answer.

    Show the answer: Question 15 of 16

    Answer: It destroys it and issues a new session id

    The program prints that the pre-session is gone and the session id is new. That is the order OWASP asks for.

  16. Question 16 of 16 A chat app authenticates its WebSocket with the session cookie. Which design is right?

    Choose one answer.

    Show the answer: Question 16 of 16

    Answer: Compare Origin with an exact list before the upgrade, and require a token that the signed-in page got over HTTPS

    The handshake is an ordinary GET that carries cookies. The server decides, with an exact Origin check and a token that a page on another site cannot get.

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.

  1. 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?

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

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

    Model answer, Pro. First step and follow-ups

Flashcards · 8 cards

Recall it, then check

Say the answer to yourself before you look: recalling it is what makes it stick.

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

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

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

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

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

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

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

  8. 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.
  • SameSite removes 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-Site and Origin refuse 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

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.