CSRF Protection
What Is CSRF? In simple terms: Cross-Site Request Forgery (CSRF) tricks a logged-in user's browser into sending a request to your site without the user meaning to . Imagine Asha is logged into her sho
What Is CSRF?
In simple terms: Cross-Site Request Forgery (CSRF) tricks a logged-in user's browser into sending a request to your site without the user meaning to. Imagine Asha is logged into her shop account. She visits another website, which contains a hidden form that submits "change my email to attacker@example.com" to your shop. Her browser automatically sends her session cookie along — and your site, seeing a valid session, makes the change.
Technically: Browsers attach cookies to requests based on the destination, not on which site created the request. CSRF exploits this: an attacker's page causes the victim's browser to send a state-changing request (form POST, image request, fetch()) to the target application with the victim's credentials. The application must therefore verify that a request was intentionally sent from its own pages — typically with a secret CSRF token that other sites cannot read.
What an Attack Looks Like
<!-- On evil.example — the victim only has to open this page -->
<form action="https://shop.example/account/email" method="post" id="f">
<input type="hidden" name="email" value="attacker@example.com">
</form>
<script>document.getElementById('f').submit();</script>
The attacker cannot read the response (the same-origin policy prevents that), but the damage is already done: the email is changed, and a password reset can follow. Any action that changes state is a potential target — changing settings, placing orders, transferring money, deleting data.
The Defenses
| Defense | How It Works | Role |
|---|---|---|
| Synchronizer token | A random secret stored in the session and included in every form; the server compares them | Primary defense |
| SameSite cookies | The browser does not send the session cookie with cross-site POST requests (Lax/Strict) | Strong second layer |
| Origin/Referer check | The server verifies that the request's Origin header matches its own site | Additional check |
| Custom request header | AJAX requests send a header (such as X-CSRF-Token) that cross-site forms cannot add | For JavaScript APIs |
| Re-authentication | Ask for the password again for critical actions | For the most sensitive operations |
Also essential: GET requests must never change state. Links, images, and prefetching trigger GET requests freely.
Implementing Synchronizer Tokens
<?php
final class CsrfGuard
{
private array $session;
public function __construct(array &$session)
{
$this->session = &$session;
}
public function token(): string
{
return $this->session['_csrf'] ??= bin2hex(random_bytes(32));
}
public function field(): string
{
return '<input type="hidden" name="_csrf" value="' . htmlspecialchars($this->token()) . '">';
}
public function isValid(mixed $submitted): bool
{
$expected = $this->session['_csrf'] ?? '';
return is_string($submitted)
&& $expected !== ''
&& hash_equals($expected, $submitted);
}
public function rotate(): void
{
$this->session['_csrf'] = bin2hex(random_bytes(32));
}
}
$session = []; // in a real application: $_SESSION after session_start()
$csrf = new CsrfGuard($session);
$token = $csrf->token();
echo "Token length: " . strlen($token) . "\n";
var_dump($csrf->isValid($token));
var_dump($csrf->isValid('guessed-token'));
var_dump($csrf->isValid(null));
$csrf->rotate();
var_dump($csrf->isValid($token));
Output
Token length: 64
bool(true)
bool(false)
bool(false)
bool(false)
How It Works
token()creates a 64-character hex token from 32 cryptographically secure random bytes (random_bytes()) the first time it is needed and stores it in the session. Attackers cannot guess it.field()produces a hidden form field with the escaped token.isValid()checks that a token was submitted, that the session has one, and that they match.hash_equals()compares in constant time, so response timing reveals nothing about the token.- Missing, empty, or wrong tokens are rejected — an empty session token never matches an empty submission.
rotate()replaces the token; old tokens become invalid. Rotate after login and logout.
Using It in Forms and Handlers
<?php
session_start();
$csrf = new CsrfGuard($_SESSION);
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST') {
if (!$csrf->isValid($_POST['_csrf'] ?? null)) {
http_response_code(419);
exit('Your session has expired. Please reload the page and try again.');
}
// ... process the change ...
}
?>
<form method="post" action="/account/email">
<?= $csrf->field() ?>
<label>New email <input type="email" name="email" required></label>
<button type="submit">Change email</button>
</form>
The attacker's page cannot read your page (same-origin policy), so it cannot learn the token and cannot build a valid request.
Protecting Every State-Changing Request
Rather than remembering the check in each script, run it centrally — in a front controller or framework middleware — for all POST, PUT, PATCH, and DELETE requests:
if (!in_array($_SERVER['REQUEST_METHOD'] ?? 'GET', ['GET', 'HEAD', 'OPTIONS'], true)) {
$submitted = $_POST['_csrf'] ?? $_SERVER['HTTP_X_CSRF_TOKEN'] ?? null;
if (!$csrf->isValid($submitted)) {
http_response_code(419);
exit('Invalid or expired form. Please reload the page.');
}
}
For AJAX, place the token in the page (<meta name="csrf-token" content="...">) and send it in the X-CSRF-Token header (see AJAX PHP).
SameSite Cookies as a Second Layer
session_set_cookie_params(['samesite' => 'Lax', 'secure' => true, 'httponly' => true]);
With SameSite=Lax (the default in modern browsers when no attribute is set), the session cookie is not sent with cross-site POST requests, so the attack form above arrives without a session. Tokens remain necessary because:
- older browsers and some embedded browsers do not support SameSite,
- requests from sibling subdomains count as "same-site" (
evil.shop.examplevswww.shop.example), - top-level GET navigations still carry
Laxcookies, which matters if any GET request changes state.
Checking the Origin Header
Modern browsers send an Origin header with POST requests. Verifying it is a cheap extra check:
<?php
function isSameOrigin(?string $origin, string $expected): bool
{
return $origin === null || $origin === $expected;
}
$expected = 'https://shop.example';
var_dump(isSameOrigin('https://shop.example', $expected));
var_dump(isSameOrigin('https://evil.example', $expected));
var_dump(isSameOrigin(null, $expected));
// In a handler: isSameOrigin($_SERVER['HTTP_ORIGIN'] ?? null, 'https://shop.example')
Output
bool(true)
bool(false)
bool(true)
Requests with a foreign Origin are rejected outright. A missing header is allowed here (some privacy tools strip it), so the token check remains the deciding defense.
Special Cases
| Case | Advice |
|---|---|
| Login forms | Protect them too: "login CSRF" logs the victim into the attacker's account, so data the victim enters ends up with the attacker |
| Logout | Use POST with a token, so other sites cannot log users out |
APIs with token authentication (Authorization: Bearer ...) | Not vulnerable to CSRF, because browsers do not add such headers automatically — but cookie-based APIs are |
| Multiple tabs | A per-session token works across tabs; per-form one-time tokens cause errors when several tabs are open |
| Frameworks | Laravel, Symfony, and others include CSRF protection; keep it enabled |
Common Mistakes
Changing State With GET Requests
Effect: <img src="https://shop.example/cart/clear"> performs actions.
Fix: Use POST (or PUT/PATCH/DELETE) with tokens for every change.
Comparing Tokens With == or Without Checking Emptiness
Effect: Timing leaks or bypasses with empty values.
Fix: Require a non-empty session token and use hash_equals().
Generating Tokens With rand(), uniqid(), or md5(time())
Effect: Predictable tokens.
Fix: Use random_bytes().
Forgetting AJAX Endpoints
Effect: JSON endpoints that change data are left unprotected.
Fix: Require the token header for all state-changing requests.
Relying on SameSite Alone
Effect: Gaps with subdomains and older browsers.
Fix: Combine SameSite with tokens.
Best Practices
- Never change state with GET requests.
- Protect every state-changing request with a session-bound token generated by
random_bytes(), verified withhash_equals(), in one central place. - Set session cookies with
SameSite=Lax(orStrict),Secure, andHttpOnly. - Check the
Originheader as an additional layer, and rotate tokens on login and logout. - Use your framework's built-in CSRF protection where available.
Official reference: https://www.php.net/manual/en/function.random-bytes.php