Defense in Depth
The layering order — SameSite=Lax, __Host- prefix, Fetch Metadata rejection, then a token — plus re-authentication for high-value operations.
No single control covers everything
Each CSRF defence has a specific gap, and the gaps do not overlap. That is the argument for layering — not vague caution, but the observation that the thing which defeats one layer is precisely what the next layer catches.
SameSitefails against a same-site attacker, against state-changing GET, and against non-cookie credentials.- A token fails against XSS and against a CORS misconfiguration that lets the attacker read it.
- Header checks fail when the header is absent, when a proxy rewrites it, and against a same-site attacker.
- Content-type enforcement fails to a single misconfigured body parser.
Stack them and each layer covers another's gap. The recommended order below is by cost-to-benefit: the first two are configuration changes, the third is a dozen lines of middleware, and only the fourth requires real work.
What each layer actually stops
Reading that table
Three things stand out, and they are the practical takeaways:
Re-authentication is the only column with no gaps except XSS. That is why it belongs on the operations that actually matter — password change, email change, adding a payment method, adding an API key, granting an OAuth scope, deleting an account. It is not a general CSRF defence because it cannot be applied to every request, but for the handful of actions that grant durable control it is the strongest thing available.
XSS defeats everything. Script running on the target's own origin reads the token, satisfies every header check, and is same-origin by definition. There is no CSRF control that survives it, which is why a CSRF finding on a page with XSS is redundant and why XSS should always be fixed first.
SameSite alone leaves three holes — GET, same-site attackers, and non-cookie credentials — and all three are common. Teams that adopted SameSite=Lax and removed their tokens made themselves less safe, not more.
The recommended stack
# Layers 1 and 2 are a single configuration change with no code.
Set-Cookie: __Host-session=eyJ...; Path=/; Secure; HttpOnly; SameSite=Lax
# SameSite=Lax explicitly -- not left absent. An absent attribute makes the
# behaviour Chromium-specific and opens the 120-second Lax+POST window.
#
# Lax rather than Strict: Strict logs users out when they arrive from an
# external link, which is why almost nobody ships it on a session cookie.
# Consider Strict for a SECOND cookie that gates high-value actions.
#
# __Host- is enforced by the browser and requires Secure, requires Path=/,
# and FORBIDS Domain. Host-only means a sibling subdomain cannot overwrite
# it -- the one thing SameSite cannot help with.
# Plus HSTS, so the plaintext overwrite route is closed too:
Strict-Transport-Security: max-age=31536000; includeSubDomains; preload
# And, while you are in the headers, the framing control:
Content-Security-Policy: frame-ancestors 'none'Which operations get re-authentication
Re-authentication has a real usability cost, so apply it where the action grants durable control rather than everywhere. The test: if an attacker performed this once, would they retain access afterwards?
Yes, so require it:
- Changing the email address or the recovery phone number
- Changing the password
- Adding or changing a payment method or payout destination
- Creating an API key, personal access token, or SSH key
- Linking a new identity provider or OAuth application
- Adding a user to a team, or granting admin
- Disabling MFA, or adding an MFA device
- Deleting the account or exporting all data
No, a token is enough:
- Ordinary content edits, preferences, and settings
- Anything reversible and visible to the user
And pair the high-value list with out-of-band notification — an email to the previous address on an email change, with an undo link. That does not prevent the action, but it bounds the damage and is frequently the only control that catches a successful attack after the fact.
The whole checklist
Cookies
SameSite=Laxset explicitly on the session cookie__Host-prefix,Secure,HttpOnly, noDomain- HSTS with
includeSubDomains, preloaded - Session identifier regenerated on login and privilege change
Headers
Sec-Fetch-Siterejection for cross-site state changes, mounted globally- Missing-header behaviour decided deliberately and documented
Originexact-matched against an allowlist where used;nullnever allowlistedContent-Security-Policy: frame-ancestors 'none'
Token
- Default-deny middleware, empty exemption list
- Bound to the session, rejected when absent, constant-time comparison
- Per-session, rotated on login
- Never in a URL
Requests
GET,HEAD,OPTIONSare side-effect free- Content type enforced with a
415on state-changing routes - Explicit verbs on every route; method override from a header only, applied before the CSRF check
High-value operations
- Re-authentication required
- Out-of-band notification sent, with an undo path
Subdomains
- Enumerated, and dangling DNS records audited on a schedule
same-sitetreated as untrusted if any subdomain hosts user content
Related
A server-side token bound to the session, plus the lifecycle questions: per-request versus per-session, rotation, BREACH masking, and back-button breakage.
Exactly when Lax, Strict, None, and an absent attribute attach a cookie to a cross-site request — and why the three engines disagree.
Sec-Fetch-Site, Mode and Dest, Origin checks done correctly, the missing-header decision, and Resource Isolation Policy.
Overwriting the CSRF cookie from a sibling subdomain, why SameSite offers zero protection here, and why __Host- is the fix.
Cookies are scoped by domain, not origin: who can set one, who receives one, and what __Host-, Secure, Path, and Partitioned actually change.
Enumerate, classify by auth mechanism, read the SameSite value first, then test — and write the finding with the browsers named.
What still works and what died with Lax-by-default — the honest, browser-by-browser exploitability verdict, plus the techniques that are now purely historical.