Preventing CSRF (Cross-Site Request Forgery)
Cross-site request forgery abuses a fact that is easy to overlook: browsers attach a site's cookies to every request sent to that site, regardless of which page the request came from. If a victim is logged into your banking site in one tab, and in another tab they visit a page controlled by an attacker, that malicious page can contain a form or an image tag that submits a request straight to your banking site. The browser happily attaches the victim's session cookie to that request, because as far as the browser is concerned it is just a normal request to a site the victim is already logged into. The banking site has no way to tell that the request did not originate from its own form — the session looks completely valid.
What a forged request looks like
Imagine a money-transfer endpoint that accepts a POST request with an account number and an amount, and trusts the session cookie alone to identify who is making the request. An attacker hosts an unrelated page containing a hidden, auto-submitting form that targets that exact endpoint.
evil.example/free-prize.html
<body onload="document.forms[0].submit()">
<form action="https://bank.example/transfer" method="POST" style="display:none">
<input name="to_account" value="ATTACKER-ACCOUNT-123">
<input name="amount" value="5000">
</form>
</body>If a logged-in victim simply visits that page, their browser submits the hidden form to the bank automatically, cookies and all, and the transfer goes through as if the victim had clicked the button themselves — because from the bank's point of view, a logged-in session made a well-formed request. The victim never entered a password, never saw a confirmation dialog, and may not even know the request happened.
The fix: a per-session CSRF token
The defense is to require every state-changing request to include a secret value that only your own page could have known — something an attacker's page, hosted on a different origin, has no way to read or guess. Generate a random token once per session, store it server side in $_SESSION, and embed it as a hidden field in every form that performs a sensitive action.
Generating and storing a token
<?php
session_start();
if (empty($_SESSION['csrf_token'])) {
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}random_bytes() is important here — it uses a cryptographically secure source of randomness, unlike rand() or mt_rand(), which are predictable enough that an attacker could potentially guess or brute-force a token generated with them.
transfer-form.php — embedding the token
<form action="/transfer" method="POST">
<input type="hidden" name="csrf_token" value="<?php echo htmlspecialchars($_SESSION['csrf_token'], ENT_QUOTES, 'UTF-8'); ?>">
<input type="text" name="to_account">
<input type="text" name="amount">
<button type="submit">Transfer</button>
</form>Validating the token with hash_equals()
When the form is submitted, compare the token that came back with the one stored in the session, and reject the request if they do not match exactly. Use hash_equals() for this comparison rather than the == or === operators.
transfer.php — validating the token
<?php
session_start();
$submitted = $_POST['csrf_token'] ?? '';
$expected = $_SESSION['csrf_token'] ?? '';
if ($expected === '' || !hash_equals($expected, $submitted)) {
http_response_code(403);
exit('Invalid or missing CSRF token.');
}
// safe to process the transfer nowSameSite cookies as a complementary defense
The SameSite cookie attribute tells the browser not to send a cookie along with requests that originate from another site, which directly undercuts the mechanism CSRF depends on. Setting SameSite=Lax (the current browser default when nothing is specified) still allows the cookie on simple top-level navigations like clicking a link, but blocks it on cross-site form POSTs and background requests — exactly the pattern the forged form above relies on. SameSite=Strict is even tighter, blocking the cookie on cross-site navigation entirely, at the cost of logging a user out if they arrive at your site by clicking a link from somewhere else.
Setting SameSite on the session cookie
<?php
session_set_cookie_params([
'secure' => true,
'httponly' => true,
'samesite' => 'Lax',
]);
session_start();SameSite is a strong complementary defense, not a full replacement for CSRF tokens — older browsers may not honor it, and some legitimate cross-site flows (payment redirects, single sign-on callbacks) need cookies to travel cross-site, which can force a looser setting on parts of an application. Tokens remain the primary, framework-independent defense.
Generate a random per-session token with
random_bytes(), neverrand()ormt_rand().Embed the token as a hidden field in every form that performs a state-changing action.
Validate the submitted token against the session token with
hash_equals(), never==or===.Set
SameSite=LaxorStricton session cookies as a second, complementary layer.Apply CSRF protection to every state-changing request — POST, PUT, DELETE — not just login forms.