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=Nonecookies — required for cross-site embeds (Stripe Elements, third-party iframes). Lose Lax protection.- Subdomain attacks —
a.example.comandb.example.comare “same site” — a vuln onbcan CSRFa’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.comis sent to all subdomains by default. SetDomain=carefully. - Cross-site OAuth callbacks —
SameSite=Laxblocks the POST callback from the auth provider. Workaround: use the GET callback variant, or setSameSite=None; Securefor the OAuth session. - First-party SSO with subdomains —
auth.example.comsetting a cookie for*.example.comis fine; SameSite scope is the registrable domain. - Iframe-based widgets — third-party iframes can’t read your cookies even with
SameSite=None; needsStorage Access API(more complex). - Chrome’s “3rd party cookie” deprecation — even
SameSite=Nonecookies 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:
- Identify state-changing endpoints (anything modifying server state).
- For each, attempt a cross-origin request from a test page (
<form>submit,fetch,<img>). - Verify the server rejects (403 or similar).
- 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=Laxis the modern default, but you can’t rely on browser version. Set explicitly.SameSite=NonerequiresSecure— won’t be set otherwise on modern browsers.- Form-based CSRF with
<form action>still works againstSameSite=Nonecookies — needs a CSRF token. - PUT/DELETE with
Content-Type: application/jsontriggers CORS preflight → likely blocked → CSRF-safe by accident. SameSite=Stricton 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=Nonefor 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
- Token storage trade-offs: 06_token_storage.md
- CORS deep: 04_cors_security_angle.md
- Backend CSRF: ../../backend/25_security/03_xss_csrf.md
Further reading
- MDN — SameSite cookies: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie/SameSite
- OWASP CSRF Cheat Sheet: https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html
- web.dev — SameSite cookies explained: https://web.dev/articles/samesite-cookies-explained