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 SameSite policy with a host-locked prefix
  • State-changing routes require a valid, per-session anti-forgery token
  • The server rejects unsafe-method requests whose Origin does 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.

Token Check vs SameSite Rejection of a Forged Request An attacker page triggers a cross-site request. On the token path the server compares a submitted token against the session secret and rejects the mismatch. On the SameSite path the browser strips the session cookie so the request arrives unauthenticated. Attacker page forges POST /transfer Token defense SameSite defense Server compares submitted token vs session secret Browser strips cross-site cookie before sending Rejected 403 token missing/wrong Rejected 401 no session cookie

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.

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.