frontend / security / 02_csrf.md

CSRF — SameSite Cookies, Anti-CSRF Tokens

7 min read source

CSRF — SameSite Cookies, Anti-CSRF Tokens

TL;DR

CSRF (Cross-Site Request Forgery) = attacker tricks the user’s browser into making a state-changing request to your site, using the user’s session cookies. Classic example: a malicious site’s <img src="https://bank.com/transfer?to=attacker&amount=1000"> fires the request with the user’s session cookie attached. The modern defense is mostly SameSite=lax cookies (browser default in 2020+); for stronger protection, the double-submit token pattern. Apps that use Authorization: Bearer <token> (not cookies) are largely immune to CSRF — but vulnerable to XSS reading the token.

Interview Q&A

Q: How does CSRF work, concretely?

A: The attacker’s site triggers a request to your site with the user’s cookies attached.

<!-- evil.com -->
<form action="https://bank.com/transfer" method="POST">
  <input name="to" value="attacker">
  <input name="amount" value="1000">
</form>
<script>document.forms[0].submit();</script>

When the user visits evil.com (logged into bank.com in another tab):

  • evil.com’s form submits to bank.com.
  • Browser attaches bank.com’s session cookies automatically (cookies go to their owning domain regardless of who originated the request).
  • Bank.com sees a “logged in user requesting transfer.”

The attacker can’t read the response (same-origin policy blocks that), but the side effect — the transfer — happens.

GET-based CSRF via <img>/<script> works the same way: the request fires, cookies attach.

Q: What is SameSite and how does it stop CSRF?

A: A cookie attribute controlling when the browser sends the cookie cross-origin:

SameSite value Sent on cross-site requests?
Strict never — only same-origin
Lax (modern default) only on top-level navigation (<a href>, manual address bar), not on <form action> POSTs, <img>, fetch
None always (must pair with Secure)

SameSite=Lax has been the browser default since Chrome 80 (2020). It blocks the canonical CSRF: cross-site POSTs / <img src> requests don’t carry the cookie. Most sites are CSRF-safe by default just by using cookies + Lax.

Exception: SameSite=None (for cross-site embeds — third-party iframes that need to be logged in) loses CSRF protection. Use a CSRF token in that case.

Q: When is SameSite not enough?

A:

  • SameSite=None cookies — required for cross-site embeds (Stripe Elements, third-party iframes). Lose Lax protection.
  • Subdomain attacksa.example.com and b.example.com are “same site” — a vuln on b can CSRF a’s cookies if both are on the same registrable domain.
  • GET requests with side effects — even with SameSite=Strict, a top-level navigation from a malicious link can send the cookie. Don’t put side effects on GET — use POST/PUT/DELETE.

For these cases: double-submit token.

Q: Double-submit token pattern.

A: Server sets a random token as a cookie (readable by JS) AND requires the same token in a request header. Attacker can’t read your cookies cross-origin → can’t put the right header on the forged request.

// Server, on login or first request
res.cookie("csrf_token", randomToken, { sameSite: "lax", secure: true });

// Client, before any state-changing request
const token = getCookie("csrf_token");
fetch("/api/transfer", {
  method: "POST",
  headers: { "X-CSRF-Token": token, "Content-Type": "application/json" },
  body: JSON.stringify(payload),
});

// Server validates: header value === cookie value
function csrfMiddleware(req, res, next) {
  if (req.method !== "GET" && req.headers["x-csrf-token"] !== req.cookies.csrf_token) {
    return res.status(403).send("CSRF check failed");
  }
  next();
}

The cookie itself isn’t httpOnly (client JS reads it). Cross-origin attackers can’t read the cookie → can’t set the header → forged request fails.

Variant: synchronizer token pattern — server stores token in session, page renders it in a form field, server checks the form field matches. Works for traditional server-rendered forms.

Q: What about Bearer tokens — CSRF-safe?

A: Largely yes. CSRF requires the browser to automatically attach credentials. Authorization: Bearer ... is set by your JS, not auto-attached. Cross-origin requests from evil.com don’t have your token (and CORS prevents reading it).

But: bearer tokens are vulnerable to XSS — any XSS reads localStorage.getItem("token") and exfiltrates. Cookies with httpOnly are XSS-safe; bearer tokens are CSRF-safe. Pick your trade-off:

  • httpOnly cookies + CSRF token = safe against both, more setup.
  • Bearer tokens in memory (not localStorage) = safe against CSRF, vulnerable to short-window XSS.
  • Bearer tokens in localStorage = easy, vulnerable to XSS, CSRF-safe.

Senior answer: httpOnly cookies for sessions; CSRF protection via SameSite + double-submit if cross-site embeds matter.

See 06_token_storage.md.

Q: CSRF tokens on every request, or just state-changing?

A: Just state-changing (POST, PUT, PATCH, DELETE). GET should have no side effects, so CSRF is moot for it.

If your GET endpoints do have side effects (GET /api/transfer?to=...&amount=...), you have a bigger problem — fix that, not the CSRF. RESTful semantics matter.

Q: How does CORS interact with CSRF?

A:

  • Simple requests (GET, certain POSTs) don’t trigger preflight and do carry cookies cross-origin (subject to SameSite). CSRF-relevant.
  • Non-simple requests (custom headers, JSON content-type) trigger preflight. If your server’s CORS doesn’t allow the attacker’s origin, the preflight fails → the real request never sends → CSRF blocked.

Adding a custom header (like X-Requested-With: XMLHttpRequest) makes a request “non-simple” — forces preflight, which requires CORS permission. This is a partial CSRF mitigation; not as robust as a real CSRF token, but defends against <form>-based attacks.

For JSON APIs: Content-Type: application/json triggers preflight. Combined with strict CORS, CSRF is hard.

Q: SameSite gotchas.

A:

  • Sub-domain inheritance — a cookie on example.com is sent to all subdomains by default. Set Domain= carefully.
  • Cross-site OAuth callbacksSameSite=Lax blocks the POST callback from the auth provider. Workaround: use the GET callback variant, or set SameSite=None; Secure for the OAuth session.
  • First-party SSO with subdomains — auth.example.com setting a cookie for *.example.com is fine; SameSite scope is the registrable domain.
  • Iframe-based widgets — third-party iframes can’t read your cookies even with SameSite=None; needs Storage Access API (more complex).
  • Chrome’s “3rd party cookie” deprecation — even SameSite=None cookies are restricted in third-party contexts in newer Chrome. Plan for “no third-party cookies” future.

Q: How do you actually test for CSRF?

A:

  1. Identify state-changing endpoints (anything modifying server state).
  2. For each, attempt a cross-origin request from a test page (<form> submit, fetch, <img>).
  3. Verify the server rejects (403 or similar).
  4. Verify cookies do attach (so you’re actually testing the protection, not absence of session).

Tools: Burp Suite, OWASP ZAP. Or write a tiny HTML harness on a different origin in dev.

Q: GET-based “CSRF” — is it a real thing?

A: Technically not CSRF (no state change), but information leakage<img src="https://bank.com/account.json"> causes the request. Server returns JSON; browser tries to render as image, fails silently. Attacker doesn’t read it (CORS) — but timing attacks (slow vs fast response) can leak whether a user is authenticated, what role, etc.

Mitigation: GET endpoints that return sensitive data should set CORS strictly + check origin.

Gotchas / edge cases

  • SameSite=Lax is the modern default, but you can’t rely on browser version. Set explicitly.
  • SameSite=None requires Secure — won’t be set otherwise on modern browsers.
  • Form-based CSRF with <form action> still works against SameSite=None cookies — needs a CSRF token.
  • PUT/DELETE with Content-Type: application/json triggers CORS preflight → likely blocked → CSRF-safe by accident.
  • SameSite=Strict on session cookies breaks login flow — user clicks an emailed link to a logged-in page, cookies aren’t sent, app shows logged out. Use Lax for sessions; Strict only for high-security operations.
  • OAuth callbacks sometimes need SameSite=None for the temporary state cookie — be careful.
  • GraphQL POSTs all go to one endpoint; CSRF tokens work the same — check on every mutation.

What a senior is expected to say

  • “CSRF = attacker tricks the browser into making a request with the user’s session cookies. SameSite=Lax (browser default) blocks the canonical attack vector — cross-site POSTs and <img> requests don’t carry the cookie.”
  • “For cases SameSite doesn’t cover (SameSite=None for embeds, OAuth callbacks), use the double-submit token pattern — random token in cookie + matching header. Attacker can’t read your cookies cross-origin, so can’t forge the header.”
  • “Bearer tokens (set by your JS) are CSRF-safe but XSS-vulnerable. httpOnly cookies are XSS-safe but need CSRF protection. The ‘best’ is httpOnly cookies + SameSite=Lax + CSRF token for the cross-site cases.”
  • “State-changing requests only — GET should have no side effects. RESTful methods + SameSite + token covers most attack surface.”
  • “Don’t rely on referer or origin headers — they can be missing or spoofed in some setups. Token-based or SameSite is the contract.”

Cross-references

Further reading