Frontend / Security / 02_csrf.md

CSRF — SameSite Cookies, Anti-CSRF Tokens

Updated 6 interview angles 8 min read source
On this page14
  1. How does CSRF work, concretely?
  2. What is SameSite and how does it stop CSRF?
  3. When is SameSite not enough?
  4. Double-submit token pattern.
  5. What about Bearer tokens — CSRF-safe?
  6. CSRF tokens on every request, or just state-changing?
  7. How does CORS interact with CSRF?
  8. SameSite gotchas.
  9. How do you actually test for CSRF?
  10. GET-based “CSRF” — is it a real thing?
  11. Gotchas
  12. Related
  13. Further reading
  14. Interview angle

CSRF — SameSite Cookies, Anti-CSRF Tokens

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 (set explicitly; only Chromium defaults to it); 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.

How does CSRF work, concretely?

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

html
<!-- 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.

What is SameSite and how does it stop CSRF?

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 (Chromium’s 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 Chromium’s default for cookies without a SameSite attribute since Chrome 80 (2020); Firefox and Safari do not apply it by default, so set it explicitly. It blocks the canonical CSRF: cross-site POSTs and <img src> requests don’t carry the cookie. Two caveats: Chrome sends a freshly set default-Lax cookie on cross-site top-level POSTs for its first two minutes (the “Lax+POST” exception), and SameSite is about the site, so a compromised sibling subdomain is same-site.

Two cheap layers on top: reject state-changing requests whose Sec-Fetch-Site header (Fetch Metadata, sent by all current browsers) is cross-site, and name the session cookie with the __Host- prefix, which forces Secure, Path=/ and no Domain, so a subdomain cannot overwrite it.

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.

When is SameSite not enough?

  • SameSite=None cookies — required for cross-site embeds (Stripe Elements, third-party iframes). Lose Lax protection.
  • Subdomain attacks — a.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.

Double-submit token pattern.

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.

ts
// 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.

What about Bearer tokens — CSRF-safe?

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 Token Storage — JWT in localStorage vs httpOnly Cookie.

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

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.

How does CORS interact with CSRF?

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

SameSite gotchas.

  • Sub-domain inheritance — a cookie on example.com is sent to all subdomains by default. Set Domain= carefully.
  • Cross-site OAuth callbacks — SameSite=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).
  • Third-party cookies — Safari and Firefox block or partition them; Chrome kept them (Google dropped its phase-out in 2024-2025). Anything that needs cookies in a third-party context (embedded widgets, silent re-auth iframes) fails in two of the three engines; use the Storage Access API or a first-party design.

How do you actually test for CSRF?

  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.

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

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

  • SameSite=Lax is only Chromium’s default; set it explicitly on every session cookie.
  • 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.

Further reading

Interview angle 6

  • “What is CSRF?” - the attacker gets the browser to make an authenticated request, using cookies it attaches automatically. The attacker never sees the response and does not need to - the side effect is the attack.
  • “What does SameSite change?” - it is now the primary defence. set explicitly to Lax (only Chromium defaults to it), cross-site POSTs and <img> requests no longer carry the cookie, which kills the canonical attack. The cases it does not cover are the ones needing more: SameSite=None for embedded contexts, and OAuth-style cross-site redirects.
  • “How does the double-submit token work?” - a random value in a cookie and the same value in a header that your JavaScript sets. An attacker’s page cannot read your cookie cross-origin, so it cannot produce the matching header. The cookie is sent automatically; the header is the thing only your origin can add.
  • “Bearer token or cookie?” - the trade is which attack you prefer. A bearer token set by your JavaScript is CSRF-safe because nothing attaches it automatically, and XSS-vulnerable because script can read it. An HttpOnly cookie is the reverse. The usual answer is HttpOnly plus SameSite=Lax plus a token for the cross-site cases.
  • “Can you use the Referer or Origin header instead?” - not as the contract. Both can be absent depending on referrer policy and request type, so a check that treats “missing” as “allow” is no protection and treating it as “deny” breaks legitimate traffic. Use SameSite and a token.
  • “What is the design rule underneath all of it?” - GET has no side effects. A state-changing GET is reachable from an <img> tag, which no token scheme will protect because the browser will not let you add a header to it.