Concept
The beginner framing: if you're logged into your bank in one tab, and a malicious page in another tab can make your browser send a request to that bank as you, without you ever visiting the bank's site in that tab, that's CSRF.
The precise mental model: Cross-Site Request Forgery (CSRF) exploits the fact that browsers attach cookies to a request based purely on the request's DESTINATION domain, regardless of which page or origin actually initiated the request. The attacker never needs to see, steal, or even know the value of the victim's session cookie, they just need the victim's own browser, which already holds a valid cookie for the target site, to fire a request to it. This is the key distinction from XSS: XSS forges code execution in the target's origin; CSRF forges a request, using the victim's browser purely as an unwitting cookie-delivery mechanism, with the attacker's malicious page running in a completely different origin the whole time.
<!-- evil.com, the victim is logged into bank.com in ANOTHER tab -->
<form action="https://bank.com/transfer" method="POST" id="f">
<input name="to" value="attacker-account">
<input name="amount" value="10000">
</form>
<script>document.getElementById("f").submit();</script>When this form auto-submits, the browser sends a genuine cross-origin POST to bank.com/transfer. Critically, the browser attaches bank.com's cookies to this request automatically, purely because the request's destination is bank.com, the fact that the request originated from evil.com is, by default, irrelevant to cookie attachment. bank.com's server sees a request with a valid session cookie and no way (absent an explicit defense) to tell it wasn't initiated by its own legitimate page.
Step through the attack with and without SameSite in place:
// Victim is logged into bank.com, browser holds their session cookie// Victim visits evil.com, which contains a hidden auto-submitting form:<form action="https://bank.com/transfer" method="POST"><input name="to" value="attacker-account"></form><script>document.forms[0].submit()</script>
The attacker needs no access to the victim's cookie at all, they just get the victim's own browser to fire the request. This is CSRF's defining trait: it forges the REQUEST, not the credential.
The defense that actually stops this: SameSite
Set-Cookie: session=abc123; HttpOnly; Secure; SameSite=LaxConfirmed directly against this app's own src/auth.ts / next-auth v5 defaults: the session cookie ships with SameSite=Lax out of the box. SameSite=Lax still sends the cookie on top-level navigations (a victim clicking an actual link to bank.com from an email, landing there normally) but withholds it on cross-site subrequests, exactly the auto-submitting-form-from-another-origin pattern above. The request still reaches bank.com, but arrives with no session cookie attached, so the server sees it as unauthenticated and rejects it.
SameSite=Strict → cookie withheld on ALL cross-site requests, including top-level
navigation (clicking a link from an email won't even show you
logged in), strongest, but breaks some legitimate cross-site flows
SameSite=Lax → cookie sent on top-level navigation, withheld on cross-site
subrequests (forms, fetch, img), the modern DEFAULT in most browsers
SameSite=None → cookie sent on every cross-site request regardless of origin, REQUIRES the Secure attribute; needed for legitimate cross-site
use cases (e.g. a third-party embedded widget), and offers NO
CSRF protection on its ownTry It
Predict the outcome before checking the solution.
<!-- evil.com -->
<img src="https://shop.com/api/delete-account?confirm=true">Assume shop.com's /api/delete-account endpoint is a GET request that performs the deletion directly, and its session cookie has no SameSite attribute set at all. Does this simple <img> tag on an attacker's page actually delete the victim's account?
Solution
Yes, and this is actually worse than the form-based example, because it requires zero user interaction with the attacker's page beyond simply loading it; the browser fires a GET request for every <img src> automatically to try to render it as an image, sending the shop.com session cookie along (no SameSite restriction to stop it). shop.com's server sees what looks like a normal authenticated GET request and deletes the account. This is exactly why state-changing operations (delete, transfer, update) should never be exposed as bare GET endpoints, GET requests are triggered constantly and invisibly by browsers (image tags, prefetching, link previews) with no user intent behind most of them, making them an especially dangerous CSRF surface even beyond the cookie-attachment issue itself.
Implement It Yourself
Build a minimal CSRF-token check, the classic defense predating widespread SameSite support, and still relevant as defense-in-depth:
const crypto = require("crypto");
// On rendering a form (server-side):
function generateCsrfToken(sessionId, secret) {
return crypto.createHmac("sha256", secret).update(sessionId).digest("hex");
}
// Embedded in the form as a hidden field:
// <input type="hidden" name="csrf_token" value="a1b2c3...">
// On form submission, verify:
function verifyCsrfToken(sessionId, secret, submittedToken) {
const expected = generateCsrfToken(sessionId, secret);
// constant-time comparison, avoids leaking the token via response-time differences
return crypto.timingSafeEqual(Buffer.
The mechanism: the token is tied to the victim's own session and embedded in the legitimate page's form. An attacker's cross-origin page can make the browser send a request with the victim's cookie automatically, but it has no way to read the legitimate page's HTML to extract the correct token value (that would require a same-origin read, blocked by the same-origin policy), so a forged request arrives with a missing or wrong token and fails verification. Note crypto.timingSafeEqual specifically, a naive === string comparison leaks timing information about how many leading characters matched, a real (if minor) side-channel.
Under the Hood
CSRF and XSS are frequently confused but solve different problems: XSS is about a script executing in the wrong context; CSRF is about a request being forged from the wrong context, and critically, if an attacker already has working XSS on the target site itself, they don't need CSRF at all, since same-origin script execution can already read tokens and make authenticated requests directly. CORS (see CORS Deep Dive) is a related-but-distinct mechanism: CORS governs whether a script running on one origin can read the response of a cross-origin request, it does not, by itself, stop the cross-origin request from being sent in the first place (which is why the <img> and auto-submitting-form CSRF patterns above work regardless of CORS configuration; the attacker doesn't need to read the response, only to cause the side effect).
Common Mistakes
1. Believing httpOnly cookies prevent CSRF
Set-Cookie: session=abc123; HttpOnly; Secure // ❌ no SameSite, still CSRF-vulnerablehttpOnly stops JavaScript from reading the cookie (an XSS mitigation), it has zero effect on whether the browser itself attaches the cookie to an outgoing cross-site request, which is the actual CSRF mechanism. SameSite is the attribute that addresses CSRF specifically.
2. Exposing state-changing operations via GET
app.get("/api/transfer", (req, res) => { /* moves money */ }); // ❌GET requests are triggered by browsers constantly and invisibly (image tags, link prefetching, <a> hover previews), any state-changing action reachable via GET is trivially forgeable, independent of any cookie/SameSite configuration. State-changing operations belong behind POST/PUT/DELETE.
3. Relying on SameSite=Lax alone with no additional defense
Set-Cookie: session=abc123; SameSite=Lax // reasonable baseline, but...SameSite=Lax is a strong default, but it's a browser behavior, clients on very old browsers that predate SameSite support get no protection from it at all, and certain edge cases (some subdomain configurations, specific redirect chains) have historically had inconsistent behavior across browser implementations. Defense-in-depth (CSRF tokens for genuinely sensitive operations) remains the more robust position rather than relying on a single header.
Best Practices
- Set
SameSite=Lax(orStrictwhere it doesn't break legitimate flows) on session cookies, the single highest-leverage, lowest-effort CSRF defense in modern browsers. - Never expose state-changing operations via GET, reserve GET for reads; use POST/PUT/DELETE/PATCH for anything that changes server-side state.
- Layer a CSRF token for high-value actions (payments, password changes, account deletion) even with
SameSitein place, defense-in-depth against older browsers and edge-case bypasses. - Verify tokens with a constant-time comparison (
crypto.timingSafeEqual), not a naive===, to avoid timing side-channels. - Treat CSRF and CORS as solving different problems, configuring CORS carefully does not, by itself, provide CSRF protection.
Performance Tips
SameSitecosts nothing at runtime, it's a cookie attribute the browser evaluates, not application logic; there's no performance argument against setting it.- CSRF token generation/verification (an HMAC operation) is cheap, the timing-safe comparison specifically is a fixed, constant-time cost, not proportional to any meaningful load concern.
