DEV Community

Cover image for 🍪 CSRF in 2026: Why SameSite Isn't Your Fix (and What Is)
Krishna Kumar
Krishna Kumar

Posted on

🍪 CSRF in 2026: Why SameSite Isn't Your Fix (and What Is)

A lot of teams quietly retired CSRF from their threat model a few years ago. Browsers started treating cookies as SameSite=Lax by default, the thinking went, so cross-site form posts can't carry the session anymore. Problem solved.

It isn't. SameSite narrows CSRF, but it doesn't close it, and the gaps sit in exactly the places real apps have: a GET that changes state, a framework that honours _method=POST, a sibling subdomain you don't fully control, a cookie someone set to SameSite=None to make an embed work.

This post is a fix guide. We'll look at where SameSite stops helping, then put a real defense in place in Django, Express and Go, and finish with tests that prove the fix holds. Only test what you own or are authorized to test; every host here is a stand-in (app.example.com).

What CSRF needs to work

PortSwigger's write-up boils CSRF down to three conditions:

  1. A relevant action: something worth forcing, like changing an email, adding an SSH key or moving money.
  2. Cookie-based sessions: the app identifies the user only by a cookie the browser attaches on its own.
  3. No unpredictable parameters: the attacker can know or guess every value the request needs.

Break any one and the attack dies. Tokens break condition 3. SameSite and Fetch Metadata go after condition 2: they stop the browser from sending a credentialed cross-site write, or let the server spot and refuse one.

Where SameSite stops helping

SameSite works at the level of the site, not the origin. PortSwigger defines a site as the top-level domain plus one more label (such as example.com), and most browsers also take the scheme into account. An origin is the exact scheme, host and port. So https://blog.example.com and https://app.example.com are different origins but the same site, and SameSite doesn't stop requests between them.

PortSwigger's SameSite page lists the ways around it. The ones I check first:

  • State-changing GETs. Lax cookies still go out on top-level GET navigations. If /account/delete?confirm=1 works over GET, a link or a document.location redirect is enough.
  • Method override. Some frameworks let a GET request carry a _method=POST parameter and route it as a POST. PortSwigger shows it with Symfony's _method, and other frameworks have similar parameters.
  • On-site gadgets. A client-side redirect built from user input turns a cross-site link into a same-site request, which carries cookies even under Strict.
  • Sibling domains. XSS or a takeover on any subdomain of the same site counts as "same-site". SameSite never sees it as an attack.
  • Defaults vary. PortSwigger notes that Chrome applies Lax when a cookie sets no SameSite, while other major browsers still default to None. Your session cookie's protection then depends on the browser unless you set the attribute yourself.

That's why the OWASP CSRF Prevention Cheat Sheet lists SameSite under defense in depth, next to Origin/Referer checks. The primary defenses it recommends are tokens (synchronizer or signed double-submit) and Fetch Metadata.

Where SameSite helps and where it doesn't

The defenses that actually close it

Two families, and you can run both:

1. Tokens. The server issues an unpredictable value tied to the session and requires it on every state-changing request, in a form field or a custom header. A cross-site page can't read it, so it can't send it. OWASP recommends the synchronizer token and the signed double-submit cookie. It discourages the naive double-submit (cookie value equals header value, no signature), because anyone who can write a cookie on your domain, from a sibling subdomain for example, can set both sides.

2. Fetch Metadata. Browsers send Sec-Fetch-Site on requests to secure origins, with one of four values: same-origin, same-site, cross-site or none (user-initiated, like a typed URL or a bookmark). OWASP and MDN both say it has been available in all major browsers since March 2023. The server rule is short: on an unsafe method, allow same-origin and none and reject everything else. For older browsers that don't send it, fall back to comparing Origin with the host.

The rule that makes both work: safe methods must be safe. Every one of these defenses lets GET, HEAD and OPTIONS through. A GET that writes is unprotected by design.

Django: it's on by default, so don't switch it off

Django's CsrfViewMiddleware is in the default middleware stack. Per the Django docs (6.1 is current), it:

  • checks every method except GET, HEAD, OPTIONS and TRACE;
  • verifies the Origin header, when present, against the current host and CSRF_TRUSTED_ORIGINS;
  • on HTTPS requests without Origin, does strict Referer checking;
  • requires the token from the CSRF cookie, sent back as the csrfmiddlewaretoken form field or the X-CSRFToken header.

CSRF_COOKIE_SAMESITE and SESSION_COOKIE_SAMESITE both default to 'Lax'. Leave them there unless you have a reason.

The bugs I'd look for in a Django codebase are the ways people opt out:

# Red flags in a code review
from django.views.decorators.csrf import csrf_exempt

@csrf_exempt            # "the SPA kept getting 403s"
def update_email(request):
    ...

def delete_account(request):   # state change reachable over GET
    if request.GET.get("confirm") == "1":
        request.user.delete()
Enter fullscreen mode Exit fullscreen mode

For a JavaScript frontend, read the token from the csrftoken cookie and send it as the header instead of exempting the view:

// Django's default CSRF cookie is "csrftoken"
const token = document.cookie.match(/(?:^|; )csrftoken=([^;]+)/)?.[1];

await fetch("/settings/email", {
  method: "POST",
  headers: { "X-CSRFToken": token, "Content-Type": "application/json" },
  body: JSON.stringify({ email: "new@example.com" }),
});
Enter fullscreen mode Exit fullscreen mode

And when you add a trusted origin, keep it exact and scheme-qualified, like https://admin.example.com. A wildcard such as https://*.example.com trusts every subdomain, including the ones you forgot about.

Express: csurf is archived, pick a maintained option

The old csurf middleware is archived on GitHub and marked unmaintained on npm. If your app still imports it, replace it.

One maintained option is csrf-csrf (v4.1.0 at the time of writing). It implements the stateless signed double-submit cookie pattern and is built mainly for SPAs. If your app keeps server-side sessions, its README recommends the sibling package csrf-sync, which implements the synchronizer token pattern. For the stateless case, the README requires cookie-parser registered before the protection middleware, and two options:

const express = require("express");
const cookieParser = require("cookie-parser");
const { doubleCsrf } = require("csrf-csrf");

const app = express();
app.use(cookieParser());

const { generateCsrfToken, doubleCsrfProtection } = doubleCsrf({
  getSecret: () => process.env.CSRF_SECRET,     // a long random secret
  getSessionIdentifier: (req) => req.session.id, // whatever uniquely identifies the user's session
});

app.get("/csrf-token", (req, res) => {
  res.json({ csrfToken: generateCsrfToken(req, res) });
});

app.use(doubleCsrfProtection); // GET, HEAD, OPTIONS are ignored by default
Enter fullscreen mode Exit fullscreen mode

By default it reads the token from the x-csrf-token header and uses a __Host- prefixed cookie set with SameSite=Strict. Requests without a valid token get a 403.

Go: Fetch Metadata in the standard library

Go 1.25 (August 2025) added http.CrossOriginProtection to net/http. It needs no tokens and no cookies. Filippo Valsorda's write-up "Cross-Site Request Forgery" explains the design: allow safe methods, allow Sec-Fetch-Site of same-origin or none, reject other values, and when the header is missing, compare Origin with Host.

mux := http.NewServeMux()
mux.HandleFunc("POST /settings/email", updateEmail)

cop := http.NewCrossOriginProtection()
if err := cop.AddTrustedOrigin("https://sso.example.net"); err != nil {
    log.Fatal(err)
}

log.Fatal(http.ListenAndServe(":8080", cop.Handler(mux)))
Enter fullscreen mode Exit fullscreen mode

Note that same-site is rejected too. A compromised sibling subdomain doesn't get a pass.

The same rule in any stack

If your framework has neither, the Fetch Metadata check is small enough to own. This follows the same logic as Go's Check method:

from urllib.parse import urlsplit

SAFE_METHODS = {"GET", "HEAD", "OPTIONS"}

def is_cross_origin_write(method, headers, host, trusted_origins=()):
    """Return True if a request should be rejected as cross-origin."""
    if method.upper() in SAFE_METHODS:
        return False                      # safe methods must not change state
    origin = headers.get("Origin")
    if origin in trusted_origins:
        return False                      # explicit allowlist, exact match
    site = headers.get("Sec-Fetch-Site")
    if site is not None:
        return site not in ("same-origin", "none")
    if origin is None:
        return False                      # no browser signal: not a browser form post
    return urlsplit(origin).netloc != host  # older browsers: compare Origin to Host
Enter fullscreen mode Exit fullscreen mode

Wire it into a middleware that returns 403 when it says True. It doesn't replace your auth. A request with neither header is treated as non-browser traffic and passes through to the normal session check.

Prove the fix, don't assume it

"We added the middleware" is a detection-level claim. The proof is a test that sends the cross-site write and gets refused, next to a control that shows the same-origin write still works. If you can't show both, you don't know which one you have.

For the check above:

from csrf_check import is_cross_origin_write as blocked

HOST = "app.example.com"

def test_same_origin_post_allowed():
    assert not blocked("POST", {"Sec-Fetch-Site": "same-origin"}, HOST)

def test_cross_site_post_blocked():
    assert blocked("POST", {"Sec-Fetch-Site": "cross-site",
                            "Origin": "https://evil.example"}, HOST)

def test_sibling_subdomain_blocked():
    assert blocked("POST", {"Sec-Fetch-Site": "same-site",
                            "Origin": "https://blog.example.com"}, HOST)

def test_get_is_never_blocked_so_get_must_be_safe():
    assert not blocked("GET", {"Sec-Fetch-Site": "cross-site"}, HOST)

def test_old_browser_falls_back_to_origin():
    assert blocked("POST", {"Origin": "https://evil.example"}, HOST)
    assert not blocked("POST", {"Origin": "https://app.example.com"}, HOST)
Enter fullscreen mode Exit fullscreen mode
PASSED test_cross_site_post_blocked
PASSED test_get_is_never_blocked_so_get_must_be_safe
PASSED test_old_browser_falls_back_to_origin
PASSED test_same_origin_post_allowed
PASSED test_sibling_subdomain_blocked
Enter fullscreen mode Exit fullscreen mode

In Django, the test client skips CSRF checks by default, so a stray csrf_exempt won't fail a single test unless you ask for the checks. The docs give the switch: Client(enforce_csrf_checks=True).

from django.contrib.auth import get_user_model
from django.test import Client, TestCase

class EmailChangeCsrfTests(TestCase):
    def setUp(self):
        self.user = get_user_model().objects.create_user("alice", password="stand-in-pass")
        self.client = Client(enforce_csrf_checks=True)
        self.client.force_login(self.user)

    def test_post_without_token_is_rejected(self):
        resp = self.client.post("/settings/email", {"email": "attacker@example.com"})
        self.assertEqual(resp.status_code, 403)
Enter fullscreen mode Exit fullscreen mode

On your own staging host, the same idea from the command line: replay the request your frontend sends with a test account's session, change only the browser signals, and expect a 403:

curl -s -o /dev/null -w '%{http_code}\n' -X POST https://staging.app.example.com/settings/email \
  -H 'Cookie: sessionid=STAND_IN_TEST_SESSION' \
  -H 'Sec-Fetch-Site: cross-site' -H 'Origin: https://evil.example' \
  --data 'email=attacker%40example.com'
Enter fullscreen mode Exit fullscreen mode

curl only fakes the headers a browser would send. It tells you the server enforces the rule. The full proof is a real cross-site page in a lab, which is what the exercises below are for.

Try it yourself

Practice on targets built for it:

Wrapping up

SameSite is a good default, and you should keep it. It's not a CSRF defense you can stop thinking about, because "same-site" is wider than you'd think and GET is wider than it should be. Use your framework's token protection, or a Fetch Metadata check that rejects anything but same-origin and none on unsafe methods. Make sure GET never writes. Then prove it with a test that tries the cross-site write and fails, next to a control that succeeds.

Further reading

I'm Krishna, a pentester and the founder of Xalgorix, an open-source AI pentester that only reports vulnerabilities it has actually exploited.


🔐 If this was useful, follow me for more hands-on web & API security write-ups.

🔗 Connect with me on LinkedIn: https://www.linkedin.com/in/xalgord/

Top comments (0)