CSRF Token vs SameSite Cookie Defense
Cross-site request forgery lets a malicious page ride a victim’s ambient session cookie to perform state-changing actions the victim never intended. Two families of control dominate modern defenses: application-issued anti-forgery tokens (the synchronizer and double-submit patterns) and the browser-enforced SameSite cookie attribute. Teams routinely treat these as competing options and pick one, but they protect against different slices of the threat and fail in different places. This guide compares their coverage and gaps, then shows why a robust configuration layers both plus a server-side origin check. It is part of the Cross-Site Request Forgery (CSRF) Defense guide within Vulnerability Patterns & Web Mitigation Strategies.
Because CSRF depends on how the browser attaches session credentials, this material connects closely to session fixation prevention, which governs how those session cookies are issued and rotated in the first place.
Prerequisites
- A server-rendered or API-backed app that uses cookie-based sessions
- Ability to set cookie attributes (
SameSite,Secure,HttpOnly, prefixes) - A framework middleware layer where you can add token issuance and validation
- Access to modify response headers for the origin/referer check
Expected Outcomes
- Session cookies carry an explicit
SameSitepolicy with a host-locked prefix - State-changing routes require a valid, per-session anti-forgery token
- The server rejects unsafe-method requests whose
Origindoes not match an allowlist - The three layers are verified together so no single-control bypass succeeds
Step 1: Enable SameSite on Session Cookies
SameSite is a browser-enforced default that decides whether a cookie rides along on cross-site requests. Strict withholds the cookie on every cross-site request including top-level navigation; Lax withholds it on cross-site subrequests (images, forms posting via script, iframes) but still sends it on top-level GET navigations; None sends it everywhere and requires Secure. It is a strong, cheap baseline, but it is a default, not a per-request secret, and it leaves specific gaps a token must cover.
// Express — SameSite=Lax with a host-locked __Host- prefix
res.cookie('__Host-session', sessionId, {
httpOnly: true,
secure: true,
sameSite: 'lax', // 'strict' if no cross-site top-level entry is needed
path: '/'
// __Host- prefix forbids Domain= and forces Secure + path=/,
// which prevents a sibling subdomain from overwriting this cookie
});
The __Host- prefix is what neutralizes the subdomain-injection weakness that plagues naive double-submit implementations, because a cookie with that prefix cannot be scoped to a shared parent domain and cannot be written by sub.example.com to affect app.example.com.
SameSite has four documented gaps you must account for before treating it as sufficient. First, cookies deliberately set to SameSite=None for a legitimate cross-site integration are sent on every cross-site request, so they receive no protection at all. Second, under Lax a top-level navigation using a safe method still carries the cookie, which means any state-changing endpoint reachable by GET is exposed. Third, browser support is uneven: older engines that predate the attribute ignore it entirely and default to sending the cookie. Fourth, sibling-subdomain scoping can let one host influence another’s cookies unless the host-locking prefix is used. Each of these gaps is exactly where an application-layer token earns its place.
Step 2: Add a Token Pattern
A token check is the explicit application-layer control. The synchronizer pattern stores a per-session secret server-side and compares it against a token echoed in the request; the double-submit pattern stores the secret in a cookie and compares it against the same value sent in a header or body field, avoiding server-side session storage. Where SameSite relies on the browser doing the right thing, the token requires the attacker to know a value they cannot read cross-origin.
The diagram contrasts how each mechanism rejects a forged cross-site request.
The two token patterns trade storage for statelessness. The synchronizer pattern keeps the canonical token in server-side session state and compares the submitted value against it, which is the most robust option but requires shared session storage across your app instances. The double-submit pattern stores the token in a cookie and compares it against a copy sent in a header or form field, so the server holds no per-session state — attractive for horizontally scaled or serverless deployments, but it relies on the attacker being unable to write the comparison cookie, which is precisely the assumption the __Host- prefix and a signed token restore. For implementation-level detail on the double-submit variant, see implementing the double-submit cookie pattern in Node.js. A signed synchronizer token looks like this:
import crypto from 'node:crypto';
const SECRET = process.env.CSRF_SECRET!;
export function issueToken(sessionId: string): string {
const nonce = crypto.randomBytes(16).toString('hex');
const mac = crypto.createHmac('sha256', SECRET)
.update(`${sessionId}.${nonce}`).digest('hex');
return `${nonce}.${mac}`;
}
export function verifyToken(sessionId: string, token: string): boolean {
const [nonce, mac] = (token ?? '').split('.');
if (!nonce || !mac) return false;
const expected = crypto.createHmac('sha256', SECRET)
.update(`${sessionId}.${nonce}`).digest('hex');
// constant-time comparison prevents timing oracles
return crypto.timingSafeEqual(Buffer.from(mac), Buffer.from(expected));
}
Step 3: Combine With an Origin Check
The third layer is a server-side Origin/Referer check on every unsafe-method request. It is nearly free, needs no client changes, and covers the narrow cases where a token might be misconfigured and SameSite is set to None for a legitimate cross-site API consumer. Prefer Origin over Referer: the browser sends Origin on cross-origin state-changing requests and it cannot be forged by page script, whereas Referer is routinely stripped by a restrictive Referrer-Policy and is therefore unreliable as a sole signal. Treat a missing Origin as a decision point, not an automatic pass — allow it only for request classes that legitimately omit it, such as authenticated server-to-server callers protected by an API key or mutual TLS.
import type { Request, Response, NextFunction } from 'express';
const ALLOWED_ORIGINS = new Set([
'https://app.example.com',
'https://www.example.com'
]);
const UNSAFE = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
export function csrfGuard(req: Request, res: Response, next: NextFunction) {
if (!UNSAFE.has(req.method)) return next();
// 1) Origin allowlist (Origin is more reliable than Referer for this)
const origin = req.get('origin');
if (origin && !ALLOWED_ORIGINS.has(origin)) {
return res.status(403).json({ error: 'origin_not_allowed' });
}
// 2) Anti-forgery token
const token = req.get('x-csrf-token') ?? req.body?._csrf;
if (!verifyToken(req.session.id, token)) {
return res.status(403).json({ error: 'csrf_token_invalid' });
}
// SameSite cookie (Step 1) is the third, browser-enforced layer
return next();
}
How the three layers compare
| Dimension | CSRF token (synchronizer/double-submit) | SameSite cookie attribute | Origin check |
|---|---|---|---|
| Enforcement point | Application server | Browser | Application server |
Covers SameSite=None cross-site APIs |
Yes | No, by definition | Yes |
| Depends on browser support | No | Yes (legacy browsers vary) | Partial (Origin header) |
| Protects GET side-effect endpoints | Yes, if enforced there | Weak under Lax |
Yes |
| Subdomain / sibling-origin injection | Double-submit needs __Host-; synchronizer resistant |
Mitigated by __Host- prefix |
Yes |
| Per-request secret | Yes | No | No |
| Client changes required | Yes (send token) | No | No |
| Primary failure mode | Misconfigured or missing token | None cookies, top-level nav |
Missing/spoofable Referer only |
No single row is complete on its own, which is precisely why the last column of every attack scenario is covered by at least one of the three. The practical takeaway is to treat SameSite as a cheap, always-on baseline, the token as the enforced application-layer requirement on every state-changing route, and the origin check as a lightweight tripwire — and to never let a convenience change to one layer (such as relaxing a cookie to SameSite=None) silently remove the only control guarding an endpoint.
Verification
Exercise each layer independently, then together:
# 1. SameSite present and host-locked
curl -sI https://app.example.com/login | grep -i 'set-cookie'
# expect: __Host-session=...; Secure; HttpOnly; SameSite=Lax
# 2. Token check rejects a forged POST with no token
curl -s -o /dev/null -w '%{http_code}\n' -X POST \
https://app.example.com/transfer -d 'amount=100'
# expect: 403
# 3. Origin check rejects a mismatched Origin
curl -s -o /dev/null -w '%{http_code}\n' -X POST \
-H 'Origin: https://evil.example' \
-H 'X-CSRF-Token: whatever' \
https://app.example.com/transfer
# expect: 403
Expected: the cookie carries SameSite and the __Host- prefix, a tokenless POST is rejected with 403, and a request from a foreign origin is rejected before token validation even runs.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| Legitimate cross-site widget stops working after enabling SameSite | Cookie needs SameSite=None for that integration |
Set None; Secure only for that cookie and lean on the token + origin layers |
| Double-submit passes even from a sibling subdomain | Cookie scoped to shared parent domain, writable by sub. |
Add the __Host- prefix so the cookie is host-locked |
| Token check fails for authenticated single-page app calls | Token not attached to the XHR/fetch header | Read the token from the meta tag or cookie and send it as X-CSRF-Token |
| Origin check blocks server-to-server callers | Those clients send no Origin header |
Gate the check on presence of Origin; require an API key/mTLS for machine callers |
GET /delete?id=5 still mutates state cross-site |
State change on a safe method, which SameSite=Lax permits on top-level nav | Move the mutation to POST and require the token there |
Common Implementation Mistakes
Frequently Asked Questions
Is SameSite=Lax enough to stop CSRF on its own?
SameSite=Lax blocks cookies on most cross-site subrequests but still forwards them on top-level navigations using safe methods, and it gives no protection when an endpoint performs a state change on GET or when the cookie must be SameSite=None for a legitimate cross-site integration. Its behavior also depends on browser support, so a synchronizer or double-submit token remains the primary application-layer control.
Why combine CSRF tokens with SameSite cookies instead of choosing one?
The two controls fail in different places. SameSite is a transport-layer default that covers requests a token check might miss on legacy code paths, while a token is an explicit per-request secret that covers the gaps SameSite leaves, including SameSite=None cookies, top-level form posts in some browsers, and subdomain-scoped cookie confusion. Layering them forces an attacker to defeat both mechanisms at once.
Does the double-submit cookie pattern have weaknesses SameSite closes?
The classic double-submit pattern assumes an attacker on a sibling subdomain cannot write the cookie the server compares against, which is not always true when cookies are scoped to a shared parent domain. Setting the cookie with SameSite and a __Host- prefix, plus signing the token, closes the subdomain-injection gap a bare double-submit implementation leaves open.
Related
- Cross-Site Request Forgery (CSRF) Defense — the parent guide for the full CSRF threat class
- Implementing the double-submit cookie pattern in Node.js — a concrete build of the token layer discussed here
- Session fixation prevention — how the session cookies these defenses protect are issued and rotated