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:
- A relevant action: something worth forcing, like changing an email, adding an SSH key or moving money.
- Cookie-based sessions: the app identifies the user only by a cookie the browser attaches on its own.
- 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.
Laxcookies still go out on top-levelGETnavigations. If/account/delete?confirm=1works overGET, a link or adocument.locationredirect is enough. - Method override. Some frameworks let a
GETrequest carry a_method=POSTparameter and route it as aPOST. 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
Laxwhen a cookie sets noSameSite, while other major browsers still default toNone. 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.
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,OPTIONSandTRACE; - verifies the
Originheader, when present, against the current host andCSRF_TRUSTED_ORIGINS; - on HTTPS requests without
Origin, does strictRefererchecking; - requires the token from the CSRF cookie, sent back as the
csrfmiddlewaretokenform field or theX-CSRFTokenheader.
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() 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" }), }); 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 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))) 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 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) 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 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) 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' 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:
- PortSwigger Web Security Academy: CSRF vulnerability with no defenses, then CSRF where token validation depends on request method and CSRF where token is duplicated in cookie (the naive double-submit, broken).
- The SameSite gaps from this post: SameSite Lax bypass via method override and SameSite Strict bypass via client-side redirect.
- Then turn it around: add the Fetch Metadata check or your framework's CSRF middleware to a small app of your own, and write the failing cross-site test before the fix and the passing one after.
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
- OWASP CSRF Prevention Cheat Sheet
- PortSwigger: Cross-site request forgery and Bypassing SameSite cookie restrictions
- MDN: Sec-Fetch-Site
- Django: Cross Site Request Forgery protection and How to use Django's CSRF protection
- csrf-csrf on GitHub
- Go 1.25 release notes and Filippo Valsorda: Cross-Site Request Forgery
- CWE-352: Cross-Site Request Forgery
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/

