Browsers attach cookies to any request, whatever page caused it. Anti-CSRF tokens and SameSite cookies bind each state change to an intent the user actually saw.

Cookies travel without asking

Browsers attach cookies to a request based on the target’s domain and path, not on whether the page causing the request was the user’s earlier intent. That means a page on an attacker’s origin can make the victim’s browser send a state-changing request — a transfer, a password change — that carries the authenticated session cookie, and the server sees a request that looks like it came from the legitimate user. This is why a request’s mere presence of credentials is not evidence of the user’s intent; the browser is following the cookie rules, not the user’s choice.

A token ties the action to a page

The defense is to bind each state-changing request to a value that the attacking page cannot know. The server embeds a per-session anti-CSRF token in its own forms and reads it back on submit; the attacker’s cross-site page, lacking the token, fails the check. Pair the token property with strict methods (don’t let GET perform a state change) so tokenized requests are the only way to mutate state. It still must be compared server-side — refusing when missing or mismatched — and never accepted from an attacker-supplied origin.

SameSite adds a browser-side guard

SameSite=Lax/Strict on the session cookie stops most browsers from sending it on cross-site requests, which removes the credential from the request the attacker triggered in the first place. It is not a complete fix on its own — older browsers and some contexts still attach cookies — so it layers with the anti-token rather than replacing it. The practical posture is both: a SameSite cookie so the cookie is not the attacker’s delivery vehicle, plus a server-side token so even a cookie-carrying request still lacks the secret that proves intent.

Read Origin as an intent signal

For a state-changing POST, ask where the browser started. Browsers send Origin on POST and fetch calls and fall back to Referer on navigations; a cross-site page cannot spoof either from JavaScript because both are forbidden headers. Allow-list the exact origin, scheme plus host plus port, and reject a missing value on POST instead of treating absence as approval. Sec-Fetch-Site: cross-site gives the same signal on modern browsers. Origin evidence still needs the token, because curl can forge any header. Unlike cookie attributes, this check validates intent.

When it breaks

Four failures show up repeatedly. First, a state change behind GET: an <img> or link can trigger it, and SameSite=Lax still sends cookies on top-level GET navigation, so the forged navigation arrives authenticated. Second, a SameSite=None fallback without Secure: older browsers ignore the attribute and send the cookie cross-site anyway, and an explicit None reopens the delivery path the token was supposed to guard. Third, a token check that accepts absence: the handler verifies the value only when present, skips verification for API paths, or reads the token from the query string where it leaks through Referer and logs. Fourth, subdomain trust: Lax and Strict compare sites not origins, so evil-sub.example.com shares the site, and a subdomain that can set a cookie can overwrite the session or plant a known token. Each case leaves the cookie valid while intent is unproven.

Check it quickly

Save the following as csrf_probe.py and run python3 csrf_probe.py. It models the server gate with only the standard library: the method must be POST, the token must match the session value, and the origin signal must agree. No network or browser is involved, so the five cases run the same way on any machine.

from hmac import compare_digest
from urllib.parse import urlsplit
import sys

EXPECTED_ORIGIN = "https://bank.example"
SESSION_TOKEN = "token-issued-with-own-form"

def decide(method, origin, referer, token):
    """Mimic a CSRF gate: safe method plus token plus origin must agree."""
    if method != "POST":
        return 405, "state change requires POST"
    if not compare_digest(token or "", SESSION_TOKEN):
        return 403, "missing or wrong token"
    if origin:
        if origin != EXPECTED_ORIGIN:
            return 403, "origin mismatch"
    elif referer:
        base = urlsplit(EXPECTED_ORIGIN)
        seen = urlsplit(referer)
        if seen.scheme != base.scheme or seen.netloc != base.netloc:
            return 403, "referer mismatch"
    else:
        return 403, "no origin signal"
    return 200, "transfer accepted"

def check(actual, expected, label):
    assert actual == expected, f"{label}: got {actual}, want {expected}"
    print(f"{label}: {actual[0]} {actual[1]}")

check(decide("POST", EXPECTED_ORIGIN, "", SESSION_TOKEN),
      (200, "transfer accepted"), "legitimate form post")
check(decide("POST", "https://evil.example", "", SESSION_TOKEN),
      (403, "origin mismatch"), "forged cross-site post")
check(decide("POST", EXPECTED_ORIGIN, "", ""),
      (403, "missing or wrong token"), "missing token")
check(decide("POST", "", "https://bank.example/transfer", SESSION_TOKEN),
      (200, "transfer accepted"), "referer fallback navigation")
check(decide("GET", EXPECTED_ORIGIN, "", SESSION_TOKEN),
      (405, "state change requires POST"), "state change over GET")

print("PASS: legitimate, forged, and missing-token cases behave differently")
print(sys.version)

The legitimate POST with the correct origin and token returns 200, the cross-site origin returns 403 even with a valid token, and the missing token returns 403 even with a correct origin. The Referer fallback accepts a same-site navigation and the GET case returns 405, which shows why safe methods matter. Expected output ends with PASS: legitimate, forged, and missing-token cases behave differently plus the Python version.

The fixture checks logic only, not browser SameSite handling or TLS; re-test state-changing routes in a real browser.

Further reading

Sources