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.
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.examplecan attackapp.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/HostagainstallowedOriginsautomatically, but custom route handlers that read cookies are on you. - Express: the old
csurfpackage 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
- Log in, then from a different origin attempt a state-changing
POSTwithout the token. It must fail with403. - Confirm no state change happens on any
GET. - Verify the token check uses constant-time comparison.
- Check that
SameSiteandSecureare set on session cookies.
Checklist
- Session cookies:
HttpOnly; Secure; SameSite=Lax(orStrict) - No state changes on GET
- CSRF token (synchronizer or signed double-submit) on all mutations
-
Originverified 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.
Keep reading
All postsTroja vs. checkvibe: the closest scanner comparison (2026)
checkvibe pioneered security + SEO + AEO scanning with AI fix prompts and a 7-engine matrix. Troja matches it and adds connected deep-stack scans. The honest comparison.
ReadTroja vs. Fixnx: which AI website scanner should you use?
Fixnx runs 100+ AI-powered security, SEO and speed checks with credit-pack pricing. Troja adds AEO, connected deep-stack scans and per-finding AI fixes. Compared.
ReadTroja vs. CyScan.io: recon tool vs. fix-it scanner
CyScan.io is a free attack-surface recon scanner — endpoints, subdomains, fuzzing, screenshots. Troja is a fix-and-ship scanner with AI fixes, AEO and deep-stack scans.
Read