Skip to main content

CSRF in 2026: what SameSite fixed, and where you still need tokens

A common belief in 2026 is that CSRF is solved: browsers default cookies to SameSite=Lax, so cross-site requests do not carry the session, so there is nothing left to forge. That is partly true, and the gaps are exactly where real applications live: subdomains, GET handlers that change state, cookies set to SameSite=None for an embed, and JSON endpoints that turn out to accept a form post.

This post walks through what SameSite actually changed, when you still need a token or another check, why CORS is not a CSRF defense, and what the major frameworks do by default. MITRE tracks the class as CWE-352.

What CSRF is, briefly

The browser attaches cookies to requests based on where the request is going, not where it came from. A page on evil.example can submit a form to bank.example/transfer, and if the browser sends the bank's session cookie with it, the bank sees an authenticated request it never meant to accept. The attacker cannot read the response, and does not need to.

What SameSite changed

The SameSite cookie attribute tells the browser when to withhold a cookie on cross-site requests:

  • Strict: never sent on cross-site requests, including clicking a link to your site.
  • Lax: sent on cross-site requests only for top-level navigations that use a safe method such as GET. A cross-site form POST, fetch() or iframe does not carry it.
  • None: always sent, and must be paired with Secure.

Two details matter more than the headline. First, the default is not universal. The OWASP CSRF Prevention Cheat Sheet notes that Chromium-based browsers treat cookies without a SameSite attribute as Lax, while Firefox and Safari do not. Second, browsers that default to Lax may still send a fresh cookie on cross-site top-level POSTs for about two minutes after it was set, a compatibility allowance documented on MDN's Set-Cookie page. So set SameSite explicitly on every session cookie. Never rely on the default.

When are CSRF tokens still needed?

OWASP's position is that SameSite "is useful as a defense-in-depth control but it does not replace a proper CSRF defense in most deployments." The cases where it falls short:

Same-site is not same-origin. "Site" means the registrable domain. blog.example.com, uploads.example.com and app.example.com are all the same site, so a request from any of them carries a Lax or even Strict cookie. If any subdomain serves user content, can be taken over, or is run by someone else, it can forge requests to your app.

State-changing GET. Lax deliberately allows top-level GET navigations. A link to /account/delete?confirm=1 or /subscribe?plan=x is still forgeable.

SameSite=None cookies. Apps embedded in other sites, some single sign-on flows and widgets need cross-site cookies. Those cookies have no SameSite protection at all.

Older or unusual browsers. If your users include browsers without the defaults, you inherit the old behaviour.

If any of those apply, add a real check: a synchronizer token (a per-session secret in the form, validated server-side), a signed double-submit cookie, or an Origin and Fetch Metadata check.

Fetch Metadata and Origin checks

Modern browsers send Sec-Fetch-Site (same-origin, same-site, cross-site or none) on every request. Rejecting unsafe methods when it says cross-site stops classic CSRF without any token plumbing. OWASP calls it the primary signal for this kind of policy and makes an Origin or Referer check mandatory for requests that lack the header. Go 1.25 added exactly this to the standard library as http.CrossOriginProtection, which rejects non-safe cross-origin browser requests and allows requests with neither header, on the assumption they did not come from a browser.

CSRF and JSON APIs

"It is a JSON API, so CSRF does not apply" is true only under conditions people rarely check.

If the API authenticates with a bearer token in the Authorization header, the browser never attaches it automatically, so there is nothing to forge. This is the strongest reason SPAs that keep tokens out of cookies are not CSRF targets.

If the API authenticates with a cookie, it is a CSRF target unless the request shape forces a preflight. A fetch with Content-Type: application/json triggers a CORS preflight, which a cross-site page cannot pass. But a plain HTML form can send text/plain, application/x-www-form-urlencoded or multipart/form-data without any preflight, and the body can be crafted to look like JSON:

<form action="https://api.example.com/v1/email" method="POST" enctype="text/plain">
  <input name='{"email":"attacker@evil.example","x":"' value='"}'>
</form>

If the server parses the body as JSON regardless of the declared content type, this works. So: require Content-Type: application/json on state-changing routes and reject everything else, or require a custom header such as X-Requested-With, which a cross-site form cannot set and which forces a preflight for fetch.

// Express: reject non-JSON bodies on the API before any handler runs
app.use('/api', (req, res, next) => {
  if (['POST', 'PUT', 'PATCH', 'DELETE'].includes(req.method) && !req.is('application/json')) {
    return res.status(415).json({ error: 'Send application/json' });
  }
  next();
});

CORS misconceptions

CORS controls whether a page can read a cross-origin response. It does not stop a simple request from being sent. A strict CORS policy does not prevent the form above from reaching your server and executing.

CORS can make CSRF worse, though. A server that reflects any Origin into Access-Control-Allow-Origin and sets Access-Control-Allow-Credentials: true lets any site make credentialed requests and read the replies, including a page that contains your CSRF token. Allow specific origins you control. OWASP also warns against regexes that allow every subdomain, since one subdomain takeover then defeats the policy.

Framework defaults

Framework Default CSRF protection
Django CsrfViewMiddleware is on by default and also verifies the Origin header (Django docs)
Rails default_protect_from_forgery is true for new apps
Spring Security On by default for unsafe methods such as POST
Express Nothing built in; the old csurf middleware repository is archived
Go net/http http.CrossOriginProtection since Go 1.25, opt-in

The common way to lose these defaults is the opt-out: @csrf_exempt on a view, skip_forgery_protection in a controller, or http.csrf(csrf -> csrf.disable()) copied from a tutorial for an app that also serves browser sessions. Spring's own docs say a backend that does not serve browser traffic may disable CSRF, and one that does still needs it.

What Vulkro checks

Vulkro reports state-changing routes that accept cookie-authenticated requests with no CSRF check, and explicit opt-outs such as @csrf_exempt on a state-changing view. Rules are gated by project posture: CSRF findings are silenced on APIs that authenticate with bearer JWTs, because there is nothing for the browser to forge (vulkro scan reference). vulkro fix --write can add Secure, HttpOnly and SameSite cookie flags and remove an explicit CSRF opt-out, refusing whenever the code is ambiguous. In Salesforce, Vulkro for Salesforce checks Aura actions and Apex REST methods that change state, and the csrf-on-post-off org setting.

FAQ

Do I need CSRF tokens if I use SameSite cookies?

Often yes. SameSite does not cover subdomains on the same registrable domain, state-changing GET routes, SameSite=None cookies or browsers without Lax by default. Add a token, or an Origin and Sec-Fetch-Site check, unless none of those apply.

Are JSON APIs vulnerable to CSRF?

Cookie-authenticated JSON APIs are, if they accept bodies sent as text/plain or form encodings, because those requests skip the CORS preflight. Require application/json or a custom header. APIs that use an Authorization bearer token are not CSRF targets.

Does CORS prevent CSRF?

No. CORS decides whether a page can read a response. A simple cross-site request is still sent and processed. A permissive CORS policy with credentials can actually help an attacker read CSRF tokens.

Start a 14-day trial at /start/. Related reading: Broken access control is still the top breach and Next.js Server Actions are public endpoints.