Every few years someone declares CSRF dead. "SameSite cookies fix everything." "Modern browsers handle it." "Just use JWT." And every few years a pen tester files a CSRF finding in a production app built by people who believed that.
CSRF — Cross-Site Request Forgery — is still alive in 2026. The attack surface has shrunk, but it hasn’t vanished. And the defenses that actually work are now a layered combination of three techniques that each cover the other’s blind spots. This article breaks down exactly how to implement them correctly, what breaks if you get it wrong, and how to think about the threat model before you reach for any of them.
Why CSRF Still Matters
The classic CSRF scenario: a user is logged into bank.example.com. They visit evil.example.com. The attacker’s page fires a cross-origin POST to bank.example.com/transfer. The browser helpfully includes the session cookie. Money moves.
What changed is the default browser behavior around cookies — and what didn’t change is that "default" and "safe" are not synonyms. Misconfigured origins, subdomain trust, legacy endpoints, mobile webviews, and server-side rendering frameworks all introduce gaps that attackers know about even if your team doesn’t.
The threat model in 2026 looks like this:
- Cookie-based sessions are still the dominant auth mechanism for traditional web apps
- SameSite=Lax is now the default in Chrome and Firefox — but default ≠ sufficient
- API endpoints accepting cookies (common with SSR frontends) remain fully exposed if you skip CSRF checks
- Subdomain attacks (
evil.yourapp.com) bypass SameSite entirely on some configurations - CORS misconfigurations create new surface area that didn’t exist in the old CSRF threat model
The bottom line: defense in depth. Use all three layers.
SameSite is a cookie attribute that tells the browser when to include a cookie on cross-site requests. It has three values, and the nuances matter enormously.
Strict
Set-Cookie: session=abc123; SameSite=Strict; Secure; HttpOnly; Path=/
The browser will not send this cookie on any cross-site request — including navigations. If a user clicks a link from gmail.com to your app, the cookie won’t be sent on the initial GET. They’ll appear logged out until they hit a subsequent same-site request.
This is the strongest protection. It also breaks the "click a link and land logged in" UX that most applications require. For anything that isn’t an extremely sensitive internal tool, Strict creates friction that your users will hate.
Lax
Set-Cookie: session=abc123; SameSite=Lax; Secure; HttpOnly; Path=/
Lax sends the cookie on top-level navigations using safe methods (GET, HEAD, OPTIONS) but blocks it on cross-site subresource requests — form POSTs, fetch calls, XHR, image loads. This is the browser default since 2020 in Chrome and since 2022 across most major browsers.
This covers the classic CSRF attack against state-changing POST endpoints. It does not protect GET endpoints that have side effects (and yes, those exist in the wild — please don’t write them).
None
Set-Cookie: session=abc123; SameSite=None; Secure; HttpOnly; Path=/
Sends the cookie on all cross-site requests. Required for legitimate cross-site embedding: OAuth callbacks from third-party IdPs, payment iframes, federated widgets. If you’re using this, you need the other two layers even more.
One practical approach for apps that need Strict-level protection without the UX hit: issue two session cookies. A long-lived SameSite=Lax cookie for auth continuity, and a short-lived SameSite=Strict cookie for state-changing operations. When you need to perform a sensitive action, check the Strict cookie. If it’s missing (e.g., the user arrived via a cross-site link), redirect them through a same-site confirmation flow to re-issue it.
GitHub uses a variant of this pattern. It’s worth the implementation complexity for high-value apps.
Gotcha: The 2-Minute Lax Window
Chrome and some other browsers implement a "Lax+POST" compatibility mode: for the first 2 minutes after a cookie is issued, it’s sent even on cross-site POST requests — mimicking what None does. This was introduced to avoid breaking OAuth flows where the IdP POSTs back to your redirect URI.
If you’re relying only on SameSite=Lax for CSRF protection, a targeted attacker who can control the timing of their CSRF attempt can exploit this window. It’s a narrow attack surface, but it’s real.
This is one reason SameSite alone isn’t sufficient for high-stakes endpoints.
This is a stateless CSRF defense that works well for APIs, SPAs, and microservice architectures where you can’t or don’t want to maintain a server-side token store.
How It Works
- The server generates a cryptographically random token and sets it as a cookie (readable by JavaScript, so not HttpOnly)
- The client reads that cookie and includes the token in a request header (or form field)
- The server verifies that the header value matches the cookie value
A cross-origin attacker can trigger a request with the session cookie (thanks to SameSite=None or a misconfigured policy), but they can’t read the CSRF cookie to include it in the header — same-origin policy blocks that.
Implementation
Server-side (Node.js/Express example):
const crypto = require('crypto');
const cookieParser = require('cookie-parser');
app.use(cookieParser());
// Middleware to issue the CSRF cookie
function issueCsrfToken(req, res, next) {
if (!req.cookies['csrf-token']) {
const token = crypto.randomBytes(32).toString('hex');
res.cookie('csrf-token', token, {
httpOnly: false, // Must be readable by JS
secure: true,
sameSite: 'Strict',
path: '/'
});
}
next();
}
// Middleware to validate on state-changing requests
function validateCsrf(req, res, next) {
if (['GET', 'HEAD', 'OPTIONS'].includes(req.method)) {
return next(); // Safe methods pass through
}
const cookieToken = req.cookies['csrf-token'];
const headerToken = req.headers['x-csrf-token'];
if (!cookieToken || !headerToken) {
return res.status(403).json({ error: 'CSRF token missing' });
}
// Constant-time comparison to prevent timing attacks
const cookieBuf = Buffer.from(cookieToken);
const headerBuf = Buffer.from(headerToken);
if (cookieBuf.length !== headerBuf.length ||
!crypto.timingSafeEqual(cookieBuf, headerBuf)) {
return res.status(403).json({ error: 'CSRF token invalid' });
}
next();
}
app.use(issueCsrfToken);
app.post('/api/transfer', validateCsrf, transferHandler);
Client-side (fetch):
// Helper to read a cookie by name
function getCookie(name) {
return document.cookie
.split('; ')
.find(row => row.startsWith(name + '='))
?.split('=')[1];
}
async function securePost(url, body) {
const csrfToken = getCookie('csrf-token');
return fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': csrfToken // Include in custom header
},
credentials: 'include', // Send session cookie
body: JSON.stringify(body)
});
}
The Signed Double-Submit Variant
Plain double-submit has a weakness: if an attacker can set a cookie on your domain (via a subdomain they control, an XSS payload, or a man-in-the-middle position), they can set the CSRF cookie to a known value and include that value in the header.
The fix is to sign the token with a server-side secret:
const crypto = require('crypto');
const CSRF_SECRET = process.env.CSRF_SECRET; // Min 32 bytes, from env
function signToken(token) {
const hmac = crypto.createHmac('sha256', CSRF_SECRET);
hmac.update(token);
return `${token}.${hmac.digest('hex')}`;
}
function verifySignedToken(signed) {
const [token, sig] = signed.split('.');
if (!token || !sig) return false;
const expected = crypto
.createHmac('sha256', CSRF_SECRET)
.update(token)
.digest('hex');
return crypto.timingSafeEqual(
Buffer.from(sig),
Buffer.from(expected)
);
}
Now even if an attacker plants a cookie, they can’t forge a valid signature without the server secret.
This is the most common implementation mistake I see: setting the CSRF cookie as HttpOnly: true. HttpOnly prevents JavaScript from reading the cookie — which is the entire point of how this pattern works. JavaScript must be able to read the CSRF cookie to inject it into headers. The session cookie should be HttpOnly. The CSRF cookie should not.
Layer 3: Origin Header Checking
This is the simplest layer and should be the first line of defense for any endpoint that accepts state-changing requests.
HTTP/1.1 introduced the Referer header. Fetch and XHR added the Origin header. For cross-origin requests initiated by a browser, Origin is sent and cannot be spoofed by JavaScript (it’s a "forbidden header" per the Fetch spec).
Origin: https://yourapp.com
On a legitimate same-origin request (user clicking a button on your site), Origin will match your domain. On a cross-site request from an attacker’s page, it will be Origin: https://evil.example.com — or missing (for some direct navigation scenarios).
Server-Side Validation
# Python/Flask example
from urllib.parse import urlparse
from flask import request, abort
ALLOWED_ORIGINS = {
'https://yourapp.com',
'https://www.yourapp.com',
}
def check_origin():
origin = request.headers.get('Origin')
referer = request.headers.get('Referer')
if request.method in ('GET', 'HEAD', 'OPTIONS'):
return # Safe methods pass through
# Prefer Origin, fall back to Referer
if origin:
if origin not in ALLOWED_ORIGINS:
abort(403, f"Origin not allowed: {origin}")
return
if referer:
parsed = urlparse(referer)
referer_origin = f"{parsed.scheme}://{parsed.netloc}"
if referer_origin not in ALLOWED_ORIGINS:
abort(403, f"Referer not allowed: {referer_origin}")
return
# Neither header present — conservative: reject
# For some internal tools, you might allow this
abort(403, "No Origin or Referer header")
When Origin Is Missing
Origin can be absent on direct navigation, browser extensions, some mobile clients, and SameSite=Strict navigations where the browser strips it for privacy. Your policy on "no Origin header" depends on context:
- Public APIs: reject requests with no Origin if they’re supposed to come only from your frontend
- Internal tools: you may need to whitelist requests with no Origin header for non-browser clients
- Never silently allow an absent header on sensitive endpoints — decide explicitly
Gotcha: Subdomain Trust
SameSite cookies and origin checking both treat subdomains as same-site. evil.yourapp.com is same-site as yourapp.com from a cookie and SameSite perspective (they share the registrable domain). If an attacker can control any subdomain — via a DNS takeover, an abandoned service, or a client-controlled subdomain feature — they can bypass both SameSite and basic origin checking.
Mitigations:
- Audit your DNS for dangling CNAME/A records pointing to decommissioned cloud resources
- In your origin allowlist, be explicit — don’t wildcard-allow
*.yourapp.com - Use
__Host-cookie prefix:Set-Cookie: __Host-session=...; Secure; Path=/; SameSite=Lax— this prefix forces the browser to reject the cookie if the domain attribute is set or if it arrives over HTTP, making subdomain planting harder
Set-Cookie: __Host-session=abc123; Secure; Path=/; SameSite=Lax; HttpOnly
Putting It Together: The Production Stack
Here’s the full picture for a typical web app in 2026:
# Nginx snippet for security headers
add_header X-Frame-Options "DENY";
add_header X-Content-Type-Options "nosniff";
add_header Referrer-Policy "strict-origin-when-cross-origin";
Cookie Stack:
__Host-session → SameSite=Lax, HttpOnly, Secure, no Domain attr
__Host-csrf → SameSite=Strict, NOT HttpOnly, Secure
(Strict because it only needs to be sent same-site anyway)
Validation on POST/PUT/PATCH/DELETE:
1. Check Origin header → must be in allowlist
2. Check X-CSRF-Token header → must match signed __Host-csrf cookie
3. Session lookup from __Host-session
Skip CSRF on:
- GET, HEAD, OPTIONS (safe methods)
- Webhook endpoints authenticated by HMAC signature over payload
- API endpoints using Bearer token auth (no cookies in play)
The cookie prefix __Host- replaces a need to manually configure Domain= and ties the cookie to the exact host, not the site. Use it.
What About JWT in Headers?
If your API authenticates purely via Authorization: Bearer <token> and there are no session cookies, CSRF is not a threat. A cross-origin attacker can’t read the JWT from your client’s memory or localStorage — they can only trigger requests that include cookies, which the browser sends automatically.
The problem comes when teams mix patterns: JWT in a cookie, or token endpoint that also accepts cookie auth as a fallback, or a /graphql endpoint that accepts both. Audit your auth flow. If it ever involves cookies being sent automatically by the browser, CSRF is in scope.
Framework Defaults
Most mature frameworks have built-in CSRF protection. Know what they actually do before assuming you’re safe:
- Django: CSRF middleware enabled by default using a synchronised token pattern. Skips the check for requests with
SameSite=Strictsessions since Django 4.x — verify yourCSRF_COOKIE_SAMESITEsetting - Rails:
protect_from_forgery with: :exceptionis the default. Uses a per-session masked token. Solid. - Laravel: CSRF middleware applied to all routes except the
apimiddleware group. If you’re using Sanctum for SPA auth, read the Sanctum docs carefully — it has its own CSRF token flow - Next.js / React: no built-in CSRF protection. You’re on your own. Use a library or roll the double-submit pattern above
- Express: nothing by default. Use
csurf(deprecated, usecsrf-csrfinstead) or implement manually
Testing Your CSRF Defenses
Don’t trust your implementation without probing it. A quick test matrix:
# 1. Test that cross-origin POST is blocked
curl -X POST https://yourapp.com/api/transfer \
-H "Origin: https://evil.example.com" \
-H "Cookie: __Host-session=<valid_session>" \
-d '{"amount":1000}' \
-v
# Expect: 403
# 2. Test that same-origin POST works
curl -X POST https://yourapp.com/api/transfer \
-H "Origin: https://yourapp.com" \
-H "Cookie: __Host-session=<valid>; __Host-csrf=<signed_token>" \
-H "X-CSRF-Token: <same_signed_token>" \
-d '{"amount":1000}' \
-v
# Expect: 200
# 3. Test missing CSRF header is rejected
curl -X POST https://yourapp.com/api/transfer \
-H "Origin: https://yourapp.com" \
-H "Cookie: __Host-session=<valid>; __Host-csrf=<signed_token>" \
-d '{"amount":1000}' \
-v
# Expect: 403
# 4. Test tampered CSRF token is rejected
curl -X POST https://yourapp.com/api/transfer \
-H "Origin: https://yourapp.com" \
-H "Cookie: __Host-session=<valid>; __Host-csrf=<signed_token>" \
-H "X-CSRF-Token: deadbeef" \
-d '{"amount":1000}' \
-v
# Expect: 403
Run these in your CI pipeline. Add mutation testing: flip one byte in the signature, remove the origin header, send from an unlisted origin.
The Short Version
If you want to walk away with one diagram in your head:
- SameSite=Lax is your default baseline. It kills the classic CSRF attack. Don’t rely on it alone.
- Signed double-submit cookies handle everything SameSite misses: pre-auth flows, the 2-minute Lax window, subdomain-cookie injection.
- Origin header checking is cheap, stateless, and catches anything that slips through the cookie layer.
Layer them. None of them is sufficient alone. Together they cover each other’s blind spots, and you get defense that holds up even when one layer has a browser-specific quirk or an edge case in your deployment.
Stop treating CSRF as a checkbox on a compliance form. It’s a real attack with real exploit toolkits and real CVEs filed against apps that thought SameSite cookies were enough. Do the work.