Level 2 · 25 min
CSRF: Same-Origin Defense in Depth
Cross-Site Request Forgery (CSRF) abuses the browser''s habit of attaching cookies to every request to a given origin, regardless of who initiated the request. If a victim is logged into bank.com and visits attacker.com, a hidden form on attacker.com can POST to bank.com/transfer and the bank receives an authenticated request. The fix is breaking the assumption that ''cookies present = user intent''.
How CSRF Actually Works
Three preconditions: (1) the victim is authenticated to the target site (session cookie present); (2) the target site uses cookies for authentication (Bearer headers do not auto-attach, so APIs that use Authorization: Bearer ... are not vulnerable in the classic sense); (3) the target site has a state-changing endpoint that does not validate user intent beyond the session cookie. The attacker''s page submits a hidden form '<'form action="https://bank.com/transfer" method="POST"'>''<'input name="to" value="attacker"'>''<'input name="amount" value="5000"'>''<'/form'>' followed by document.forms[0].submit(). The browser obediently includes the bank''s session cookie. The bank''s server sees a valid session and processes the transfer.
SameSite Cookies — The Modern Default
Since 2020, Chrome and Firefox treat cookies without an explicit SameSite as SameSite=Lax. Lax means the cookie is only sent on top-level cross-site GETs (a user clicking a link to your site) — not on cross-site POSTs / fetches. This eliminates classic CSRF for properly-categorised endpoints (state changes are POST/PUT/DELETE; safe reads are GET and idempotent). SameSite=Strict goes further: the cookie is not sent on any cross-site request, including link clicks — which breaks the UX of a user clicking a link in a Slack DM to land on an authenticated page. Most apps want Lax for the session cookie. SameSite=None requires Secure (HTTPS) and is for explicit third-party flows (e.g., embedded SaaS widgets). The trap: SameSite is a cookie attribute, not an authentication mechanism — a misconfigured CDN or subdomain can still bypass it.
Tokens and Headers as Defense in Depth
Synchronizer token pattern: server issues a per-session unguessable CSRF token (random 128 bits), embeds it in every form as a hidden field and in JS-accessible state, and validates that incoming POSTs include the matching token in a hidden field or X-CSRF-Token header. Because the attacker''s site can not read the token (Same-Origin Policy blocks reading bank.com''s response), the forged form has the wrong (or no) token and the request is rejected. Double-submit cookie: a stateless variant where the server sends the token in a cookie and expects the client to echo it in a custom header — this works because the attacker can set their own cookie but can not read the victim''s cookie value. CSRF only matters for state-changing endpoints — a strictly idempotent GET endpoint is not a CSRF vector (but if your GET has side effects, that is a separate bug). Modern SPAs that use Authorization: Bearer headers (not cookies) sidestep CSRF entirely — at the cost of needing a secure place to store the token (see lesson sec-006 on OAuth/PKCE).
Code example
// Spring Security — CSRF protection enabled by default for sessions
http
.csrf(csrf -> csrf
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
.ignoringRequestMatchers("/api/webhooks/**")) // exclude webhooks (they have their own auth)
.sessionManagement(s -> s.sessionFixation().migrateSession())
.authorizeHttpRequests(a -> a.anyRequest().authenticated());
// Set the session cookie correctly
response.addHeader("Set-Cookie",
"SESSION=" + sessionId + "; " +
"HttpOnly; " + // no JS access
"Secure; " + // HTTPS only
"SameSite=Lax; " + // CSRF defense
"Path=/; " +
"Max-Age=3600");
// Client (SPA) — read CSRF cookie, echo in header
const token = document.cookie
.split("; ")
.find(c => c.startsWith("XSRF-TOKEN="))
?.split("=")[1];
fetch("/api/transfer", {
method: "POST",
headers: { "X-CSRF-Token": token, "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({to, amount})
});