All posts
VibeZero Team8 min read

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/delete as 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.

ShareXLinkedIn