Why architecture matters here

The reason CSRF is an architectural concern and not just a checkbox is that it stems from a property of the platform, not a bug in your code. Cookie-based sessions grant ambient authority: any code running in the browser that can cause a request to your origin borrows the user's authenticated session for free. This is convenient — it is why you stay logged in across tabs — but it means authentication alone can never distinguish a request your app initiated from one a malicious page initiated. The server must add a second signal that only your own pages possess, and it must add it to every request that changes state. Miss one endpoint and you have a hole; the defense is only as strong as its least-protected mutating route.

This is why the safe/unsafe method distinction is load-bearing. HTTP defines GET (and HEAD, OPTIONS) as safe: they must not change state. If your app honors that contract — GETs only read — then CSRF protection only needs to cover POST, PUT, PATCH, and DELETE, which shrinks the attack surface to exactly the mutating routes. But the contract is a discipline you enforce, not something the framework guarantees: a single 'GET /account/delete?id=5' convenience endpoint reintroduces CSRF on a route your token middleware may not even guard, because it wasn't expecting a GET to be dangerous. Architecture matters because the defense's correctness depends on an invariant — unsafe methods for state changes, protection on all of them — that must hold across the whole application.

The second architectural reason is defense in depth. Each individual control has a gap. SameSite cookies are excellent but depend on browser support and correct configuration, and 'Lax' still permits some top-level navigations. Synchronizer tokens are strong but can leak through referrer headers, logs, or XSS. Origin headers are reliable when present but occasionally absent. Layering them means an attacker must defeat all of them at once: get past SameSite, forge or steal the token, and spoof the origin — a bar that is, in practice, unreachable from a cross-site context without a separate XSS vulnerability. The layers are not redundancy for its own sake; each covers the others' specific weaknesses, and that composition is the actual security property.

A further architectural subtlety is the difference between authentication mechanisms. CSRF is fundamentally a problem of automatically-attached ambient credentials, and cookies are the canonical example — but so are HTTP Basic auth and client TLS certificates, which the browser also attaches without the page's involvement. Token-based schemes that require the application to explicitly read a credential from storage and attach it (an Authorization header carrying a bearer token) are not vulnerable to classic CSRF, because a cross-site page cannot read that storage. This is why the defense you need is dictated by how you authenticate: cookie sessions require the full CSRF apparatus, whereas a pure header-token API is CSRF-immune but shifts the burden to protecting the token from XSS instead — the two vulnerabilities trade off, they don't both vanish.

Advertisement

The architecture: every piece explained

Top row: the delivery-side controls. The browser holds the session cookie and attaches it automatically. SameSite is an attribute on that cookie: Strict withholds it on all cross-site requests (safest, but breaks inbound links that expect a logged-in landing), while Lax — the modern default — sends it only on top-level GET navigations, blocking the cross-site POST that classic CSRF relies on. SameSite is the cheapest and broadest layer, but you do not rely on it alone. The CSRF token is the request-level secret: in the synchronizer pattern the server generates a random token, stores it in the session, and embeds it in every form; the request must echo it back. In the double-submit pattern the token is set as a cookie and also sent in a header or field, and the server checks the two match — stateless, but must be hardened (signed/HMAC'd) so an attacker can't set both. The Origin/Referer check compares the request's stated origin against an allowlist of your own hosts.

Middle row: the server-side gate. On every unsafe request, server validation checks all present signals: a valid session, a token that matches (and hasn't expired), and an origin on the allowlist. Only if the checks pass does the state change execute; otherwise the request is rejected with a 403 before any write. The token store or signer is the mechanism behind the token — per-session storage for synchronizer tokens, or an HMAC secret for signed stateless tokens bound to the session id so they can't be replayed across users. For APIs, CORS and preflight provide an orthogonal guard: if a state-changing endpoint requires a custom header (e.g. X-CSRF-Token or a content-type the browser treats as non-simple), the browser issues a preflight OPTIONS request that a cross-site attacker's simple form cannot satisfy.

Bottom rows: the invariants. The safe/unsafe method split is the foundation — GETs read, mutations use POST/PUT/PATCH/DELETE and are all guarded. Defense in depth is the explicit stance: SameSite plus token plus origin, so no single failure is fatal. The ops strip is what keeps it working over time: token rotation on privilege changes, metrics on validation failures (a spike may be an attack or a broken deploy), explicit handling of login and logout CSRF (an attacker logging a victim into the attacker's account, or forcibly logging them out), and correct SPA integration where the token must reach a JavaScript client that reads it and attaches it to fetch/XHR calls.

The choice between synchronizer and double-submit tokens is itself an architectural decision with real consequences. The synchronizer pattern stores the token server-side in the session, which makes it strong — the server holds the authoritative copy — but requires session state and coordination in a horizontally scaled backend. The signed double-submit pattern stores nothing server-side; it relies on an HMAC over the session identifier so the server can verify a token it never persisted, which scales cleanly across stateless nodes but is only as strong as the signing discipline. Teams running stateful sessions usually prefer synchronizer tokens; teams committed to stateless services reach for signed double-submit — and the failure mode of picking the stateless option without the signing is exactly the weak double-submit vulnerability discussed below.

CSRF defense — prove intent, not just identity, on state-changing requeststhe browser sends cookies; make that not enoughBrowserholds session cookieSameSite cookieLax / StrictCSRF tokensynchronizer / double-submitOrigin / Referer checktrusted-origin allowlistServer validationsession + token + originState changewrite only if all passToken store / signerper-session or signedCORS + preflightguards custom-header APIsSafe vs unsafe methodsGET read-only, POST/PUT/DELETE guardedDefense in depthlayers, not a single controlOps — token rotation + failure metrics + login/logout CSRF + SPA integrationrequestattachverifycheckallowissueoperateoperate
CSRF defense in depth: SameSite cookies, a synchronizer or double-submit token, and origin checks together ensure a state-changing request proves user intent, not just an ambient session cookie.
Advertisement

End-to-end flow

Trace a protected 'transfer funds' action. The user loads the transfer page. The server, rendering the form, generates a CSRF token bound to the session — for a synchronizer token it stores it server-side; for a signed double-submit token it HMACs the session id with a server secret — and embeds it in a hidden form field. Simultaneously the session cookie carries SameSite=Lax. The user fills in the amount and submits a POST.

The browser attaches the session cookie (this is a same-site top-level form POST from the app's own page, so SameSite permits it) and includes the token field. On the server, the CSRF middleware runs before the handler: it confirms the request method is unsafe and therefore requires protection, checks that the session is valid, validates that the submitted token matches the stored one (or verifies the HMAC and that it's bound to this session), and checks that the Origin header is on the allowlist of the app's hosts. All three pass, the transfer executes, and a fresh token is issued for the next request.

Now the attack. The victim, still logged in, visits a malicious page that contains a hidden auto-submitting form targeting 'POST /transfer' with the attacker's account number. The browser prepares the request — but here the layers bite. Because the session cookie is SameSite=Lax, the browser does not attach it to this cross-site POST, so the request arrives unauthenticated and is rejected as no-session. Even if SameSite were somehow bypassed or misconfigured, the attacker's form cannot include a valid CSRF token: they cannot read the token from the victim's page (that would require XSS or violating the same-origin policy), and a guessed token won't match the stored/HMAC'd value. And even past that, the Origin header on the forged request names the attacker's site, which is not on the allowlist. The request fails three independent checks; the middleware returns 403 and logs a CSRF validation failure with the offending origin.

Consider a subtler case: the app exposes a JSON API and a single-page frontend. The SPA reads the CSRF token — delivered as a readable cookie or an initial page value — and attaches it as an X-CSRF-Token header on every mutating fetch. Because that custom header makes the request 'non-simple', the browser issues a CORS preflight; a cross-site attacker's page cannot send the custom header without a preflight the server will deny, and cannot forge the token value anyway. The state-changing endpoint is thus protected by both the token and the preflight barrier, while safe GET reads remain fast and unguarded. Every rejected request — whether attack, expired token, or a bug in a new deploy that forgot to send the header — shows up in the CSRF-failure metric, so the on-call can tell an attack spike from a regression at a glance.

It is instructive to inventory exactly what an attacker must obtain to succeed, because that inventory is the security argument made concrete. To forge a valid transfer they would need, simultaneously: the victim's session cookie attached to a cross-site request (blocked by SameSite), a valid CSRF token bound to that session (unreadable without same-origin access), and a request whose Origin header names an allowlisted host (unforgeable from a browser, which sets Origin itself). Each is independently out of reach from a cross-site context; obtaining all three at once requires the attacker to already be running code inside your origin — that is, an XSS vulnerability — at which point CSRF is moot because they can act as the user directly. This is the precise sense in which layered CSRF defense is complete against its actual threat model, and why anti-XSS is its non-negotiable partner rather than a separate project.