Troja
All posts
CSRFMar 22, 2026·15 min read

CSRF Protection: The Complete Guide for Modern Web Apps

CSRF still bites apps that lean entirely on cookies for auth. Here's how the attack works, why SameSite isn't a complete fix, and how to defend with tokens, headers, and double-submit.

By Security Desk

What CSRF actually is

Cross-Site Request Forgery (CSRF) tricks a logged-in user's browser into making a state-changing request to your app without their intent. The browser helpfully attaches the user's session cookie, so your server sees an authenticated request and obeys.

The attacker never sees the response — they don't need to. They just need the side effect: a password change, a funds transfer, an email update.

The classic attack

A user is logged into bank.example. They visit a malicious page that contains:

<form action="https://bank.example/transfer" method="POST" id="x">
  <input type="hidden" name="to" value="attacker" />
  <input type="hidden" name="amount" value="5000" />
</form>
<script>document.getElementById('x').submit()</script>

The browser submits the form to bank.example, attaching the session cookie automatically. If the server trusts the cookie alone, the transfer goes through.

Why this is a 2026 problem, not a 2010 one

You might think modern SameSite cookies killed CSRF. They reduced it — but didn't kill it:

  • SameSite=Lax (the modern default) still permits top-level navigations with GET. Any state change on GET is exposed.
  • Some browsers and embedded webviews don't enforce SameSite consistently.
  • Subdomains are same-site. A compromised blog.example can attack app.example.
  • SameSite=None (required for legitimate cross-site flows) reopens the door entirely.

So SameSite is a strong first layer, not a complete defense.

Defense 1: SameSite cookies (baseline)

Set it explicitly. Don't rely on the browser default differing across clients.

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

Use Strict for the most sensitive cookies if your UX allows it. And never perform state changes on GET — that's defense-in-depth that makes SameSite=Lax effective.

Defense 2: Synchronizer (CSRF) tokens

The gold standard. The server issues a random, per-session (or per-request) token, embeds it in forms, and verifies it on every state-changing request.

import { randomBytes, timingSafeEqual } from "node:crypto";

export function issueCsrfToken(): string {
  return randomBytes(32).toString("base64url");
}

export function verifyCsrf(sessionToken: string, submitted: string): boolean {
  const a = Buffer.from(sessionToken);
  const b = Buffer.from(submitted);
  return a.length === b.length && timingSafeEqual(a, b);
}

The attacker's forged request can't read your origin's pages, so it can't know the token. Use timingSafeEqual — a naive === leaks timing information.

Defense 3: Double-submit cookie

If you can't keep server-side session state, set the token in both a cookie and a request header. The attacker can force the cookie to be sent but can't read it to set the matching header (the same-origin policy blocks that).

// On every mutating request, require these to match:
const cookieToken = req.cookies.get("csrf")?.value;
const headerToken = req.headers.get("x-csrf-token");
if (!cookieToken || cookieToken !== headerToken) {
  return new Response("CSRF check failed", { status: 403 });
}

Harden it further by binding the token to the session (signed double-submit) so a subdomain can't plant its own cookie.

Defense 4: Origin and Referer checks

For non-GET requests, verify the Origin header matches your site:

const origin = req.headers.get("origin");
const allowed = ["https://app.example"];
if (req.method !== "GET" && origin && !allowed.includes(origin)) {
  return new Response("Bad origin", { status: 403 });
}

Origin is set by the browser and not forgeable by page JavaScript, making it a reliable signal for fetch/XHR-based APIs.

A note on token auth

If your API uses Authorization: Bearer <token> and you do not store auth in cookies, classic CSRF largely goes away — the browser doesn't auto-attach an Authorization header. But the moment you keep anything auth-related in a cookie, CSRF is back on the table.

Framework reality check

  • Next.js: Server Actions in recent versions verify the Origin/Host against allowedOrigins automatically, but custom route handlers that read cookies are on you.
  • Express: the old csurf package is deprecated — implement double-submit or use a maintained alternative.
  • Rails / Django / Laravel: ship robust CSRF middleware on by default. Don't disable it "to make the API work" — fix the client instead.

Testing your defenses

  1. Log in, then from a different origin attempt a state-changing POST without the token. It must fail with 403.
  2. Confirm no state change happens on any GET.
  3. Verify the token check uses constant-time comparison.
  4. Check that SameSite and Secure are set on session cookies.

Checklist

  • Session cookies: HttpOnly; Secure; SameSite=Lax (or Strict)
  • No state changes on GET
  • CSRF token (synchronizer or signed double-submit) on all mutations
  • Origin verified for non-GET API requests
  • Constant-time token comparison

Scan it with Troja

Troja checks your cookie flags, SameSite configuration, and whether your mutating endpoints accept cross-origin requests without a token — and ships a paste-ready fix prompt for each gap. Point it at your app and see what's exposed.

Run the scan this post is about.

Free, no signup. See what's hiding inside your walls in ~30 seconds.

Free scan · no signup · results in ~30 seconds
CSRF Protection: The Complete Guide for Modern Web Apps — Troja