Browsers can attach session cookies to attacker-triggered requests. CSRF defenses distinguish authorized request contexts; they do not prove that a person saw or intended an action.

Browser SameSite delivery rules sit above the server’s combined POST, session-bound server-stored anti-CSRF synchronizer token, and complete-origin checks. A same-site subdomain can still fail the exact-origin check.
This shows the article’s server fixture, not a browser test. SameSite reduces cookie delivery; trusted request context does not prove human intent. XSS and authentication require separate defenses.

Cookies travel without asking

Browsers attach cookies to a request according to domain, path, Secure, SameSite, and other browser rules, 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 an unpredictable 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=Strict withholds the cookie on cross-site requests; explicit Lax also permits it on top-level navigations using safe methods. These restrictions reduce cookie delivery but do not replace the token check in this design. Same-site is broader than same-origin, and browser support and request context matter. The example uses a server-stored synchronizer token, not a double-submit cookie. Cross-site scripting can defeat these defenses by acting inside the trusted origin.

Read Origin as a source signal

Check Origin when present; otherwise a server can inspect the origin portion of Referer. These fields may be absent, and Origin can be null. This fixture deliberately rejects requests without a trusted source signal as well as a valid token. Compare the complete allowed origin, including scheme, host, and port, rather than a hostname prefix. Sec-Fetch-Site adds browser context but reports sites, not an exact origin. Non-browser clients can forge these headers; origin checks address browser request forgery, not user authentication or proof of human 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, intentional cross-site cookies: SameSite=None; Secure permits that delivery, so another CSRF defense must protect mutations. Modern browsers reject None without Secure; legacy behavior is not a safe fallback. 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 able to plant parent-domain cookies can attack naive double-submit schemes or session handling. Prefer host-scoped session cookies and reject ambiguous duplicates. Credentials alone never establish request legitimacy.

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 its cases run without a browser. The fixed token is test data; a real application must generate an unpredictable token bound to the session.

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: POST-only mutation plus token plus origin must agree."""
    if method != "POST":
        return 405, "state change requires POST"
    if not isinstance(token, str) or not token.isascii() or not compare_digest(token, SESSION_TOKEN):
        return 403, "missing or wrong token"
    if origin:
        if origin != EXPECTED_ORIGIN:
            return 403, "origin mismatch"
    elif referer:
        base = urlsplit(EXPECTED_ORIGIN)
        try:
            seen = urlsplit(referer)
        except ValueError:
            return 403, "referer mismatch"
        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")

check(decide("POST", "", "", SESSION_TOKEN),
      (403, "no origin signal"), "missing origin signal")
check(decide("POST", "https://evil.bank.example", "", SESSION_TOKEN),
      (403, "origin mismatch"), "same-site subdomain")
check(decide("POST", EXPECTED_ORIGIN, "", "令牌"),
      (403, "missing or wrong token"), "non-ASCII token")
check(decide("POST", "", "https://[", SESSION_TOKEN),
      (403, "referer mismatch"), "malformed referer")

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

Additional cases reject missing source headers, a same-site subdomain, a non-ASCII token, and a malformed fallback URL. This checks decision logic, not browser cookie handling, TLS, or session generation. Its literal comparisons intentionally omit URL normalization; equivalent explicit default ports can be rejected. Re-test actual routes with synthetic accounts in an isolated browser setup.

Further reading

Sources