Command Palette

Search for a command to run...

EN·ES

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).

Key Takeaways

  • SameSite=Lax cookies (the modern default) eliminate classic CSRF for state-changing endpoints. Confirm your session cookie has it set explicitly.
  • GET endpoints must be idempotent and side-effect-free. A GET that triggers /api/delete-account is a CSRF vector even with SameSite=Lax.
  • Bearer-token APIs (Authorization header) are not CSRF-vulnerable because browsers do not auto-attach the header — but storing the token securely becomes a different problem.

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})
});