CSRF, explained
SameSite cookies made CSRF rarer, not gone. The gaps the browsers left open, a 2025 SSO account takeover, and the three defenses worth having, in order.
- security
- vibe coding
- engineering
Cross-site request forgery is an attack that uses your own user as the delivery mechanism. The attacker cannot read your API and does not need to. They only need the victim’s browser to send a request, because the browser is the thing holding the session, and it attaches that session to requests based on where they are going rather than where they came from.
That asymmetry is the whole class. A form on evil.example that posts to
yourapp.com/settings/email is, from your server’s point of view,
indistinguishable from the real form, because it carries the real cookie. The
attacker never sees the response. They do not need to: the state change already
happened.
The identifier is CWE-352 and the reference material is the OWASP CSRF prevention cheat sheet.
Why it is rarer than it was, and why it is not gone
The reason you can go a year without meeting a CSRF bug is that browsers changed
the default. Chrome 80 began treating cookies with no SameSite attribute as
SameSite=Lax, which means the cookie is not sent on cross-site subrequests at
all, and on cross-site top-level navigations only for safe methods. The classic
auto-submitting hidden form stopped working for most sites without anyone
editing any code.
“Most” is doing real work in that sentence. Four gaps remain, and they are documented by the people who built the default, in the Chromium SameSite FAQ.
The two-minute exception. Chrome carved out Lax-allowing-unsafe: a cookie
set without a SameSite attribute less than two minutes ago is still sent on
top-level cross-site POST requests. It exists so single sign-on flows that POST
back to a site kept working. It means a freshly issued session cookie, which is
to say the one your login just set, is exposed to classic CSRF for the first two
minutes of its life, and the login flow is exactly when a victim is being
redirected around. The FAQ describes it as temporary. It is still the behavior
you inherit if you do not set the attribute yourself, which is the argument for
setting it explicitly: an explicit SameSite=Lax does not get the exception.
GET requests that change state. Lax sends cookies on top-level GET
navigation. If a link can delete something, an <img src> or a redirect is a
working attack under the default policy. This is a design rule, not a browser
one: safe methods must be safe.
Same-site is not same-origin. SameSite compares registrable domains, so
blog.example.com and app.example.com are the same site. A markup injection on
a marketing subdomain, or a
subdomain you no longer control, sits inside
the boundary. This is the same trap as
CORS misconfiguration, where a
reflected Origin check treats a sibling host as trusted.
Cookies deliberately set to None. Any integration that needs
cross-site cookies, embedded widgets, some payment flows, some SSO, ships
SameSite=None; Secure and opts back out of the protection entirely.
It still happens, in 2025 shapes
The modern CSRF bug is rarely a form post. It is a callback URL.
In November 2025 Langfuse published
an advisory
for CVE-2025-65107: without
explicit AUTH_<PROVIDER>_CHECK configuration, an authenticated user tricked
into visiting a crafted URL could have an attacker’s SSO account linked to
their account. The attacker starts their own SSO flow, aborts it, and gets the
victim to complete the callback. From then on the attacker’s identity provider
login opens the victim’s account.
The remediation is one line, AUTH_<PROVIDER>_CHECK set to pkce,state, and
the shape of the bug is worth more than the fix. It is a state-changing request
(link an identity), triggered by navigation, authenticated by an ambient
session, with no proof that the user intended it. That is the CSRF definition
exactly, arriving through OAuth rather than through a <form>.
The other place it survives is any JSON API authenticated by a cookie. Developers
often assume JSON is immune because a cross-origin fetch with
Content-Type: application/json triggers a preflight, and the preflight fails.
That is true for fetch. It is not true for a form, which can send
text/plain, application/x-www-form-urlencoded or multipart/form-data
cross-site with no preflight at all. If your endpoint parses a body that arrives
as text/plain because your framework is permissive, the preflight was never the
control you thought it was.
The three defenses, in order
1. Set SameSite explicitly, and use Strict where you can.
Set-Cookie: session=...; HttpOnly; Secure; SameSite=Lax; Path=/
Lax is the practical default because Strict breaks inbound links from email
and other sites, which usually reads to the user as being logged out. The common
pattern is Strict for anything genuinely sensitive and Lax for the main
session. HttpOnly belongs here for a different reason: it does not stop CSRF,
it stops the token theft that XSS otherwise makes trivial. You can check what
your deployed site actually sends with the
cookie security checker.
2. Check the origin on the server. This is the cheapest real control and the
most under-used. Browsers send Origin on every cross-origin request, current
ones send it on same-origin POSTs too, and they also send Sec-Fetch-Site,
which answers the question directly:
const ALLOWED = new Set(["https://app.example.com", "https://www.example.com"]);
export function sameOriginOnly(req, res, next) {
if (["GET", "HEAD", "OPTIONS"].includes(req.method)) return next();
// Sec-Fetch-Site is the direct answer when present.
const fetchSite = req.get("sec-fetch-site");
if (fetchSite && fetchSite !== "same-origin") {
return res.status(403).json({ error: "cross-site request refused" });
}
// Origin as the fallback. Exact string match, never startsWith or includes.
const origin = req.get("origin");
if (origin && !ALLOWED.has(origin)) {
return res.status(403).json({ error: "cross-site request refused" });
}
return next();
}
Note what is not here: no includes, no regex, no “ends with our domain”. Those
are the mistakes that turn an origin check into an origin suggestion, and they
are the same mistakes catalogued in the CORS post. Note also that a missing
Origin header is treated as acceptable here, which is a deliberate choice for
compatibility with non-browser clients; if your API is only ever called by
browsers, requiring the header is stricter and fine.
Next.js does this for you on Server Actions. As
the Next.js security post
describes, Server Actions are POST-only and the framework compares Origin
against Host, aborting on a mismatch, with the serverActions.allowedOrigins
config for reverse-proxy setups. Route handlers get no such protection, which is
worth knowing, because in a typical app both exist side by side and only one of
them is covered.
3. Use a token when the stakes justify it. The synchronizer pattern is still the strongest: a random value, bound to the session, stored server-side, sent in the form or in a header, and compared with a constant-time comparison. The stateless variant, double-submit, sets the value in a cookie and requires it in a header; it is easier to deploy and weaker, because a subdomain you do not control can write cookies for the parent domain. If you use double submit, sign the token with a server key so a written cookie alone is not enough.
Whichever you pick, the token must be tied to the session and must be required on every state-changing request, not only the ones somebody remembered.
Where it hides
- Login CSRF. The attacker logs the victim into the attacker’s account, and everything the victim then does, an uploaded document, a saved card, a chat session, lands in an account the attacker can read. It sounds harmless until you name the data.
- Logout CSRF. Low severity alone, useful as a step: force a logout, then present a convincing login page.
- GET endpoints that mutate.
/admin/users/42/deleteas a link. - Password change without the current password. Requiring the old password is a CSRF defense as well as a good idea, because the attacker does not know it.
- State-changing webhooks and callbacks authenticated by a session rather than by a signature. A webhook receiver should verify an HMAC, which is a different mechanism entirely.
- Anything an admin can do, since a CSRF against an administrator is privilege escalation delivered by someone else’s browser.
The tradeoff nobody states out loud
You will read that CSRF does not apply if you use bearer tokens from
localStorage instead of cookies. That is true, and it is a trade rather than a
win: a token in localStorage is readable by any script on your origin, which
means every DOM XSS and every
stored XSS in your application becomes credential
theft rather than session riding.
Most generated apps have already made this trade without discussing it. The
browser client that Lovable and
Bolt projects ship persists the Supabase session in
localStorage by default and sends it as an Authorization header, so the
classic CSRF attack has nothing to ride on and the whole risk has moved to the
XSS side of the ledger. The practical consequence is not that you should rip it
out, it is that the sink review in the DOM XSS post is the higher-value hour on
those stacks, and CSRF work belongs to whatever cookie-authenticated surface you
add later.
When you do control the choice, cookies with HttpOnly, Secure, SameSite
and an origin check give you a credential that script cannot read and cross-site
requests cannot use. That is the better default for a browser app, and it costs
you the middleware above.
The test
Take a state-changing endpoint, build the attack page, and load it while logged in. That is it:
<!-- Save as a local file, open it while authenticated. -->
<body onload="document.forms[0].submit()">
<form action="https://app.example.com/api/settings" method="POST">
<input name="email" value="attacker@evil.example" />
</form>
</body>
Because it is a form post, it exercises the real conditions, including the
Content-Type question. If the email changes, you have the finding. If you get
a 403 from an origin check, the control works, and you learned it from an attack
rather than from reading a middleware list.
Static analysis is poor at this class, for the same reason it is poor at
broken access control: the defect is a
missing property of a request, not a pattern in a line of code. What a scanner
does reliably see is the cookie attributes on a live response, which is why
cookie-no-samesite is one of the checks our own scanner records against a
deployed URL and why that check belongs to the running-app
layer rather than the code one.
Decide which requests change state, require proof that your own application issued them, and stop relying on a browser default with an exception list.