CSRF Protection from Scratch: What the Frameworks Are Doing for You
The attack first, because it explains every design choice: a user is logged into yoursite.com; they visit evil.com, which contains <form action="https://yoursite.com/transfer" method="POST"> and auto-submits it. The browser attaches yoursite's session cookie automatically - that's what cookies do - and your server sees a perfectly authenticated request the user never intended. Cross-Site Request Forgery: the attacker rides the victim's session.
The defense: require proof that the request came from a page you served - a secret evil.com can't read (same-origin policy blocks it) and therefore can't include.
The server side (whole thing)
function csrf_token(): string
{
return $_SESSION['csrf'] ??= bin2hex(random_bytes(32));
}
function csrf_field(): string
{
return '<input type="hidden" name="_token" value="' . csrf_token() . '">';
}
function csrf_verify(): void
{
$sent = $_POST['_token'] ?? $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
if (!hash_equals($_SESSION['csrf'] ?? '', $sent)) {
http_response_code(419);
exit('Request expired - please reload and try again.');
}
}
// Every state-changing endpoint, first line:
csrf_verify();
Design choices decoded: random_bytes for unguessability (not Math.random-grade randomness); one token per session (per-request tokens break back-button and multi-tab flows for marginal gain); hash_equals against timing leaks; checking POST body or header so both forms and AJAX use one verifier; 419 because that's the "page expired" convention users' frameworks trained them on.
The client side
<form method="POST" action="/settings"><?= csrf_field() ?> ...</form>
<meta name="csrf-token" content="<?= csrf_token() ?>">
const token = document.querySelector('meta[name="csrf-token"]').content;
fetch('/api/save', {
method: 'POST',
headers: { 'X-CSRF-TOKEN': token, 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
// (jQuery shops: set it once in $.ajaxSetup and forget it)
The second layer and the exemptions
SameSite=Lax cookies independently block the auto-submitted-form attack in modern browsers - run both layers; defense in depth is cheap here. And know the legitimate exemptions: webhook endpoints (Stripe can't read your token - they authenticate by signature instead) and true public APIs using token auth. Everything else that changes state - including "harmless" logout and preference endpoints - verifies. GET handlers, meanwhile, must never change state, or no token scheme can save you.
Rotate the token on login (session_regenerate_id time), keep it out of URLs (referrers leak), and that's genuinely the whole discipline. Thirty lines - and the next time Laravel throws a 419 at you, you'll know precisely which line of this you violated.
Full-stack web developer sharing practical tutorials and building tools that ship.
Got something on your mind?
My inbox is open - no forms disappearing into the void here.
- Just say hello Found a tutorial useful? Spotted a mistake? Tell me.
- Hire me for a project Have something custom in mind? Let's talk scope and timelines.
- Product support Bought something here? I'll help you get it running.
I usually reply within 1-2 business days.