CSRF is one of those topics that stays vague no matter how many times you read the definition. “Cross-site request forgery: an attack that tricks a user into submitting a request they did not intend.” True, and it explains nothing.
It becomes obvious the moment you write the attack. It is nine lines of HTML, and once you have seen it work the defence stops being a ritual.

The vulnerable form
An ordinary settings page. The user is logged in, there is a session cookie, and this changes their email address:
<form method="POST" action="/account/email">
<input type="email" name="email">
<button>Update email</button>
</form>
// /account/email
session_start();
if (empty($_SESSION['user_id'])) { exit('Not logged in'); }
update_email($_SESSION['user_id'], $_POST['email']);
That checks the user is logged in. It looks careful. It is completely open.
The attack page
Now the attacker’s page, hosted anywhere. The victim only has to open it while logged in to your site — from an email, a forum post, an ad:
<body onload="document.forms[0].submit()">
<form method="POST" action="https://yoursite.com/account/email">
<input type="hidden" name="email" value="attacker@evil.com">
</form>
</body>
The page loads, the form submits itself, and the browser does exactly what browsers are designed to do: it sends the cookies belonging to yoursite.com along with the request, because the request is going to yoursite.com. Your server sees a valid session and a well-formed POST, and changes the email.
The attacker then uses “forgot password” on the address they now control.
Nothing was stolen. The attacker never saw the session cookie, never read the response — the browser’s same-origin policy stops both. They did not need either. They needed the browser to send a request, and the browser was happy to.
The one sentence that makes it click
Your session cookie proves who the request is from. It proves nothing about where it came from or whether the person meant to send it.
That is the entire vulnerability. Authentication answers a question CSRF does not ask.
What the token adds
A CSRF token is an unpredictable value that your server put in the page, and that must come back with the submission.
The attacker can make the browser send a request. They cannot read your page to find out what the token was — same-origin policy stops that, and it is the one thing it does reliably. So they cannot include it, and a request without it is rejected.
Generate it once per session:
session_start();
if (empty($_SESSION['csrf'])) {
$_SESSION['csrf'] = bin2hex(random_bytes(32));
}
Put it in the form:
<input type="hidden" name="csrf"
value="<?= htmlspecialchars($_SESSION['csrf'], ENT_QUOTES) ?>">
Check it before anything else happens:
if (!isset($_POST['csrf'], $_SESSION['csrf'])
|| !hash_equals($_SESSION['csrf'], $_POST['csrf'])) {
http_response_code(419);
exit('Session expired. Please reload and try again.');
}
Three details worth being deliberate about.
random_bytes, not rand or uniqid. The token’s only property is that it cannot be guessed, and those two can be.
hash_equals, not ===. The same timing-safe comparison that belongs anywhere you compare a secret.
And the check comes before anything with an effect. A validation error that runs first and writes a log line has already let the attacker write a log line.

Why SameSite is not the whole answer
Modern browsers default cookies to SameSite=Lax, which means the cookie is not sent on cross-site POST requests. That kills the attack above, and it is a genuine improvement.
It is not sufficient, for four reasons.
Lax still allows top-level GET. If any state-changing route answers a GET — /account/delete?id=5, a logout link, an unsubscribe — a plain <img src> still triggers it with cookies attached. The real rule underneath is that GET must not change state, and SameSite does not enforce it.
Subdomains are same-site. SameSite means same registrable domain, not same origin. A request from blog.yoursite.com to app.yoursite.com is same-site and sends the cookie. If a subdomain is a WordPress install somebody else maintains, that is a path in.
You do not control the browser. Defaults vary, older browsers are still in use, and some corporate configurations change it. A defence you cannot verify is not a defence you can rely on.
An explicit SameSite=None undoes it. Plenty of applications set that for an embed or a payment redirect and forget the consequence.
SameSite is a good second lock. The token is the first one.
Making it work with AJAX
The hidden input covers normal forms. A single-page front end needs the token somewhere JavaScript can read it, which is what the meta tag convention is for:
<meta name="csrf-token" content="<?= htmlspecialchars($_SESSION['csrf'], ENT_QUOTES) ?>">
const token = document.querySelector('meta[name="csrf-token"]').content;
fetch('/account/email', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': token },
body: JSON.stringify({ email })
});
Then accept either source server-side, because a real application has both kinds of request:
function csrf_ok(): bool
{
$sent = $_POST['csrf']
?? $_SERVER['HTTP_X_CSRF_TOKEN']
?? '';
return !empty($_SESSION['csrf'])
&& is_string($sent)
&& hash_equals($_SESSION['csrf'], $sent);
}
Putting it in a header rather than the body has a small extra benefit: a cross-origin request cannot set a custom header without passing a CORS preflight, which your server declines.

When you cannot use a session: the double-submit cookie
The token above lives in the session, which means the server has to hold state. On a load-balanced setup without shared sessions, or a stateless service, there is a variant that needs no server storage at all.
Set the token as a cookie, and require the same value back in a header or a form field. The server compares the two and keeps nothing:
// Issue it once, readable by JavaScript so the front end can echo it back
if (empty($_COOKIE['csrf'])) {
setcookie('csrf', bin2hex(random_bytes(32)), [
'path' => '/',
'secure' => true,
'httponly' => false, // the front end has to read it
'samesite' => 'Lax',
]);
}
// Verify: the cookie and the submitted value must match
function csrf_double_submit_ok(): bool
{
$cookie = $_COOKIE['csrf'] ?? '';
$sent = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? $_POST['csrf'] ?? '';
return $cookie !== '' && hash_equals($cookie, $sent);
}
It works for the same reason the session version does: the attacker can make the browser send the cookie, but cannot read it to put the matching value in the body.
Two caveats that matter. The cookie must be readable by JavaScript, so it cannot be HttpOnly — which is fine for a CSRF token and would not be for a session. And because cookies are shared across subdomains, anything that can set a cookie on your domain can set this one; if a subdomain is not fully under your control, sign the token with a server-side secret rather than comparing it raw.
Given the choice, the session-backed version is simpler and harder to get wrong. Reach for double-submit when you genuinely have no session to put it in.
Four mistakes that show up in real code
A new token on every page load. It seems more secure and it breaks the site: open two tabs and the first one’s token is dead. Worse, the back button and a long-lived form both fail, so people learn to reload and resubmit — which trains exactly the wrong habit. One token per session is correct.
Protecting only some routes. The attacker picks the one that was missed. Check it in middleware, once, for every unsafe method, and exempt deliberately rather than include deliberately.
Putting the token in a URL. Query strings end up in server logs, in browser history, and in the Referer header sent to whatever the page links to. The token belongs in a body field or a header.
Treating the failure as an error page. Almost every real CSRF rejection is an expired session, not an attack. A 419 saying “session expired, reload and try again” is what the user needs. “Forbidden” gets you a support ticket.
What about APIs
A stateless API authenticated with a bearer token in an Authorization header does not need CSRF protection, and the reason is precise: the browser does not attach that header automatically. The attack depends on the browser volunteering the credential, and here it does not.
The moment you authenticate an API with a cookie, CSRF applies again in full. That trips people up on the hybrid setup — a JavaScript front end on the same domain, authenticated by session cookie, calling JSON endpoints. That is cookie auth, and it needs the token.
What to do on Monday
If you are on a framework, this is already built — Laravel’s @csrf and its VerifyCsrfToken middleware, Symfony’s form component. Check that nothing has been added to the exempt list without a reason, because that list is where this quietly goes wrong.
If you are not on a framework, the code above is the whole of it: one token per session, one check in one place before anything with an effect, and hash_equals to compare.
Then write the attack page once against your own staging site. It takes two minutes, it is the fastest way to be certain the protection is actually wired up, and finding out that way is much better than the alternative.
Worth pairing with rate limiting on the login — the two cover opposite ends of the same session: one stops somebody getting in, the other stops somebody using a session that is already open.



