caduh

CSRF Still Matters

9 min read

A practical guide to CSRF protection in modern apps: SameSite cookies, synchronizer tokens, custom headers, Fetch Metadata, and the CORS mistakes that quietly reopen old holes.

CSRF Still Matters

SameSite cookies, SPAs, API patterns, and common mistakes

Goal: help you protect cookie-backed web apps and APIs from cross-site state-changing requests without cargo-culting SameSite, CORS, or framework defaults.


TL;DR

  • CSRF matters whenever the browser automatically attaches credentials such as session cookies.
  • If your app uses cookies for auth, protect every state-changing request.
  • Use your framework’s built-in CSRF protection when possible.
  • SameSite helps, but it is not a complete defense.
  • For classic server-rendered apps, use a synchronizer token.
  • For API-driven apps that still use cookies, use custom headers, origin checks, and ideally Fetch Metadata as defense in depth.
  • Don’t use GET for actions that change server state.
  • CORS is not CSRF protection. In some cases, bad CORS config makes things worse.

1) Why CSRF Still Exists in “Modern” Apps

People hear about JWTs, SPAs, and fetch() and assume CSRF is old news.

It isn’t.

The core issue is simple: if the browser will automatically include credentials with a request, another site may be able to trick the browser into sending a request your server treats as authenticated.

That means CSRF still matters for:

  • server-rendered apps with session cookies
  • SPAs that call APIs using cookie-backed sessions
  • cross-subdomain setups that share cookies
  • admin panels and internal tools that rely on browser sessions

If your browser client authenticates using an Authorization: Bearer ... header that JavaScript adds manually, classic CSRF risk is much lower because the attacker’s site cannot make the browser attach that header automatically. But that does not solve XSS, token theft, or bad CORS.


2) The Threat Model in One Paragraph

A user is logged into app.example.com.

They visit evil.example.net in another tab. That malicious site causes the browser to submit a form, load an image, or navigate to a URL on your app. If the browser includes the user’s cookies and your server only checks “is this session valid?”, the action succeeds.

That is CSRF.

The attacker usually cannot read the response because of browser isolation rules, but for CSRF they often do not need to. The damage is the state change itself.


3) Start Here: Are You Actually CSRF-Exposed?

You are exposed if all of these are true:

  1. The request can change state
  2. The browser will automatically include credentials
  3. The server does not verify intent with a CSRF defense

High-risk examples

  • change email
  • change password
  • create API key
  • transfer money
  • submit admin action
  • add payment method
  • invite user / change role
  • delete data

Lower-risk cases

  • pure read-only GET pages
  • APIs called only by non-browser clients
  • bearer-token APIs where browsers do not auto-send credentials

Even then, don’t get sloppy: frameworks, method override, and browser quirks can turn “it should be safe” into production regret.


4) The Practical Defense Matrix

| App type | Auth style | Primary defense | Extra defenses | |---|---|---|---| | Server-rendered web app | Session cookie | Synchronizer CSRF token | SameSite, Origin/Referer checks | | SPA + same-origin API | Session cookie | Custom header + server token validation | SameSite, Fetch Metadata | | SPA + BFF | Session cookie between browser and BFF | Framework/BFF CSRF protection | SameSite, Origin checks | | API using bearer token in Authorization | No auto-sent cookie | Usually not classic CSRF-exposed | Focus on XSS, token storage, CORS | | Cross-site embedded flow | SameSite=None cookie | Explicit CSRF token + origin policy | Tight CORS, narrow cookie scope |

If you only remember one thing, remember this:

Cookies create CSRF risk. Tokens prove intent. SameSite and Origin checks add guardrails.


5) Best Default for Server-Rendered Apps: Synchronizer Tokens

For stateful apps with server sessions, the default pattern is a synchronizer token.

How it works

  • Server creates a random CSRF token tied to the user session.
  • Server renders that token into forms or sends it to the frontend in a safe way.
  • Browser submits the token with every state-changing request.
  • Server validates that the token matches the session.

HTML form example

<form method="post" action="/account/email">
  <input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
  <input type="email" name="email" />
  <button type="submit">Save</button>
</form>

Backend check (pseudocode)

function requireCsrf(req, res, next) {
  const sessionToken = req.session.csrfToken;
  const requestToken = req.body.csrf_token || req.get("x-csrf-token");

  if (!sessionToken || !requestToken || sessionToken !== requestToken) {
    return res.status(403).send("Bad CSRF token");
  }

  next();
}

Per-session vs per-request tokens

  • Per-session tokens are easier and usually enough.
  • Per-request tokens reduce replay windows but can create UX annoyances like stale tabs and broken back-button flows.

For most product teams, per-session tokens + strong session handling is the practical default.


6) SPAs and Cookie-Backed APIs: Use a Custom Header

If your SPA talks to your API using session cookies, you still need CSRF protection.

A practical pattern is:

  • browser gets a CSRF token from the app
  • JavaScript sends it in a custom header
  • server checks both the session and the CSRF token

Why this helps:

  • attackers can submit cross-site forms
  • attackers generally cannot make a plain HTML form send your custom header
  • adding a custom header usually makes the request non-simple, which brings browser CORS rules into play for script-based cross-origin requests

Example: server issues token

app.get("/api/csrf", (req, res) => {
  if (!req.session.csrfToken) {
    req.session.csrfToken = crypto.randomUUID();
  }

  res.json({ csrfToken: req.session.csrfToken });
});

Example: frontend sends token

const { csrfToken } = await fetch("/api/csrf", {
  credentials: "include",
}).then(r => r.json());

await fetch("/api/profile", {
  method: "POST",
  credentials: "include",
  headers: {
    "content-type": "application/json",
    "x-csrf-token": csrfToken,
  },
  body: JSON.stringify({ displayName: "Kuda" }),
});

Backend check

function requireCsrfHeader(req, res, next) {
  const token = req.get("x-csrf-token");

  if (!token || token !== req.session.csrfToken) {
    return res.status(403).json({ error: "CSRF validation failed" });
  }

  next();
}

This is still not an excuse to set reckless CORS headers.


7) SameSite Helps — But It Does Not Finish the Job

SameSite controls when cookies are sent with cross-site requests.

Good defaults:

  • SameSite=Lax for many session/navigation cookies
  • SameSite=Strict for highly sensitive action cookies when UX allows it
  • SameSite=None; Secure only when you truly need cross-site cookie sending

Why SameSite is only partial protection

SameSite=Lax still allows some top-level navigations, especially with safe methods like GET. That means if you put state-changing actions behind GET, or your framework supports method override in unsafe ways, you can still get burned.

Also, SameSite is based on site, not full origin. That means sibling subdomains can still be in play depending on how you scope cookies and trust infrastructure.

Set cookies tightly

Set-Cookie: session=abc123; Path=/; HttpOnly; Secure; SameSite=Lax

Avoid broad Domain= settings unless you truly need them. Shared cookies across subdomains widen your blast radius.


8) Origin and Referer Checks: Cheap, Useful Defense in Depth

For state-changing requests, check the request origin.

Good pattern

  • Accept only your own trusted origin(s)
  • If Origin is absent, optionally fall back to Referer
  • Reject cross-site submissions to sensitive endpoints

Example

const TRUSTED_ORIGINS = new Set([
  "https://app.example.com",
  "https://admin.example.com",
]);

function requireTrustedOrigin(req, res, next) {
  const origin = req.get("origin");
  const referer = req.get("referer");

  if (origin && TRUSTED_ORIGINS.has(origin)) {
    return next();
  }

  if (!origin && referer) {
    try {
      const url = new URL(referer);
      if (TRUSTED_ORIGINS.has(url.origin)) return next();
    } catch {}
  }

  return res.status(403).send("Untrusted origin");
}

Origin checks are not a replacement for CSRF tokens, but they are simple and valuable.


9) Fetch Metadata: A Very Useful Modern Guardrail

Modern browsers send Fetch Metadata headers such as:

  • Sec-Fetch-Site
  • Sec-Fetch-Mode
  • Sec-Fetch-Dest

These let your server spot obviously cross-site requests and block them early.

Example: block cross-site unsafe requests

function requireSameSiteForUnsafeMethods(req, res, next) {
  const unsafe = !["GET", "HEAD", "OPTIONS"].includes(req.method);
  const site = req.get("sec-fetch-site");

  if (unsafe && site && site === "cross-site") {
    return res.status(403).send("Blocked by Fetch Metadata policy");
  }

  next();
}

This is excellent defense in depth, especially for modern browser traffic.

It is not enough by itself if you must support older clients, non-browser clients, or complex cross-site integration flows.


10) CORS Is Not CSRF Protection

This is one of the most common mistakes.

CORS controls whether browser JavaScript may read a cross-origin response. It does not stop a browser from sending every kind of cross-site request.

In fact, a bad credentialed CORS setup can reopen risk.

Dangerous pattern

Access-Control-Allow-Origin: https://evil.example
Access-Control-Allow-Credentials: true

If your app accepts cross-origin credentialed requests from an origin you do not fully trust, and your CSRF story is weak, you have made life easier for attackers.

Safer rules

  • never use * with credentials
  • allow only exact trusted origins
  • separate browser origins clearly
  • do not assume “we have CORS configured” means “we handled CSRF”

11) Common Mistakes That Keep Showing Up

Mistake 1: “We use SameSite, so we’re done”

No. SameSite is helpful, but it is not a full CSRF defense.

Mistake 2: State changes over GET

If GET /delete?id=123 can change state, you are asking for trouble.

Mistake 3: Broad cookie domains

A cookie scoped across many subdomains means any weaker sibling app becomes part of your trust boundary.

Mistake 4: Trusting CORS to solve CSRF

It doesn’t.

Mistake 5: Disabling framework CSRF middleware “to make the SPA work”

Usually this means the architecture is confused, not that CSRF went away.

Mistake 6: Ignoring XSS

If attackers can run JavaScript in your origin, they can often read CSRF tokens or issue authenticated requests directly. CSRF defenses do not save you from XSS.


12) What to Use by Stack

Django / Rails / Laravel / Spring MVC / ASP.NET

Use the framework’s built-in CSRF protection first. Do not re-invent it unless you truly need custom flows.

Express / Fastify / custom Node backend

Use a maintained CSRF strategy and combine:

  • session-backed token validation
  • SameSite cookies
  • origin checks
  • Fetch Metadata

SPA + BFF

This is often the cleanest browser architecture:

  • browser keeps only an HTTP-only session cookie
  • BFF talks to upstream APIs
  • BFF enforces CSRF protection on browser-originating mutations

Mobile apps / server-to-server APIs

Classic browser CSRF is usually not the main issue here. Focus more on auth, replay resistance, and token handling.


13) What “Good” Looks Like

  • All state-changing routes require POST/PUT/PATCH/DELETE, never GET
  • Session cookies are HttpOnly, Secure, and use sane SameSite
  • State-changing requests require a CSRF token or equivalent validated intent signal
  • Sensitive endpoints also check Origin and/or Referer
  • Cross-site unsafe requests are filtered with Fetch Metadata where practical
  • CORS allows only exact trusted origins and only where needed
  • The team understands the difference between CSRF, CORS, and XSS

14) 2 AM Runbook: “Are We CSRF-Protected?”

  1. List every state-changing route.
  2. Confirm none of them use GET.
  3. Check whether the browser sends cookies automatically.
  4. Verify your framework or middleware is enforcing CSRF tokens.
  5. Confirm cookies are set with HttpOnly; Secure; SameSite=....
  6. Check whether any cookie uses broad Domain= scope.
  7. Inspect CORS config for credentialed origins.
  8. Add or verify Origin/Referer checks on sensitive routes.
  9. Add Fetch Metadata blocking for unsafe cross-site browser requests.
  10. Test from a separate origin, not just from your local app.

If steps 3, 4, and 7 are fuzzy, you probably still have risk.


Appendix: Minimal Express Middleware Stack

import express from "express";
import crypto from "node:crypto";

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

const TRUSTED_ORIGINS = new Set(["https://app.example.com"]);

function issueCsrf(req, res, next) {
  if (!req.session.csrfToken) req.session.csrfToken = crypto.randomUUID();
  next();
}

function requireTrustedOrigin(req, res, next) {
  const unsafe = !["GET", "HEAD", "OPTIONS"].includes(req.method);
  if (!unsafe) return next();

  const origin = req.get("origin");
  if (origin && !TRUSTED_ORIGINS.has(origin)) {
    return res.status(403).send("Untrusted origin");
  }

  next();
}

function requireFetchMetadata(req, res, next) {
  const unsafe = !["GET", "HEAD", "OPTIONS"].includes(req.method);
  const site = req.get("sec-fetch-site");

  if (unsafe && site === "cross-site") {
    return res.status(403).send("Cross-site blocked");
  }

  next();
}

function requireCsrf(req, res, next) {
  const unsafe = !["GET", "HEAD", "OPTIONS"].includes(req.method);
  if (!unsafe) return next();

  const token = req.get("x-csrf-token");
  if (!token || token !== req.session.csrfToken) {
    return res.status(403).send("Bad CSRF token");
  }

  next();
}

app.use(issueCsrf);
app.use(requireTrustedOrigin);
app.use(requireFetchMetadata);
app.use(requireCsrf);

Ship it with framework defaults first, then layer SameSite, token validation, origin checks, and Fetch Metadata until cross-site requests fail closed.