How to Add CSRF Protection and Secure Sessions in PHP
Give each session a random token, put it in every form and AJAX request, and reject any state-changing request whose token doesn't match, using hash_equals. Set the session cookie to Secure, HttpOnly and SameSite=Lax, turn on strict mode, and regenerate the session ID at sign-in. SameSite helps, but it doesn't replace the token.
What CSRF is, in one paragraph
Cross-site request forgery happens when another website makes the visitor's browser send a request to your app, and the browser attaches your session cookie. If your app accepts the request because the cookie is valid, the attacker has just changed an email address, cancelled a subscription or created an API key in the victim's name. The fix is to require something the other site can't read or forge: a secret token tied to the session.
The question "how do I add a CSRF token in PHP?" has been asked on Stack Overflow for well over a decade, and the answers converge on the same pattern below. OWASP's cheat sheet calls this the synchroniser token pattern.
Step 1: create one token per session
session_start();
if (empty($_SESSION['csrf_token'])) {
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
function csrf_token(): string
{
return (string) ($_SESSION['csrf_token'] ?? '');
}
Use random_bytes(), never uniqid(), rand() or an MD5 of the time. One token per session is enough for most apps and avoids the "form expired" errors that per-request tokens cause when people use the back button or several tabs.
Step 2: send it with forms and AJAX
For a classic form, add a hidden field:
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars(csrf_token()) ?>">
For JavaScript, print the token once in a meta tag and send it as a header on every request that changes something:
<meta name="csrf-token" content="<?= htmlspecialchars(csrf_token()) ?>">
fetch('/api/settings', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': document.querySelector('meta[name=csrf-token]').content,
},
body: JSON.stringify(data),
});
A custom header has a second benefit: a cross-site page can't add custom headers to a request without a CORS preflight your server never approves.
Step 3: verify before anything changes
function verify_csrf(): void
{
$token = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? ($_POST['csrf_token'] ?? '');
if (!hash_equals(csrf_token(), (string) $token)) {
http_response_code(419);
exit('Invalid CSRF token.');
}
}
Call it at the top of every POST, PUT, PATCH and DELETE handler for signed-in users. Three details matter:
hash_equals, not==. It compares in constant time and refuses type juggling.- An empty session token must fail. If the session has no token,
hash_equals('', '')would pass; the example avoids it because the token is created when the session starts, but check your own flow. - Never change state on GET. SameSite=Lax still sends cookies on top-level GET navigations, so a link to
/delete?id=5on another site works against you. If a GET must act, such as sign-out, put the token in the URL and check it.
Requests authenticated by an API key or bearer token rather than a cookie don't need a CSRF token, because the browser doesn't attach those automatically.
Step 4: harden the session cookie
ini_set('session.use_strict_mode', '1'); // reject IDs the server didn't issue
session_name('yourapp_session'); // unique per app on a shared domain
session_set_cookie_params([
'lifetime' => 60 * 60 * 24 * 30,
'path' => '/',
'secure' => true, // HTTPS only
'httponly' => true, // not readable from JavaScript
'samesite' => 'Lax', // not sent on cross-site POSTs or fetches
]);
session_start();
// After a successful sign-in, and after any privilege change:
session_regenerate_id(true);
Strict mode and regenerating the ID at sign-in together stop session fixation, where an attacker plants a session ID before the victim logs in. PHP's own session security page recommends both.
Is SameSite=Lax enough on its own?
It is a strong second layer, not a replacement. Modern browsers don't send a Lax cookie on cross-site POSTs, fetches or iframes, which blocks most classic CSRF. But Lax still allows top-level GET navigations, it treats sibling subdomains as the same site (so a compromised blog.example.com counts as same-site for app.example.com), and older browsers ignore it. OWASP lists SameSite as defence in depth alongside a token. Keep both: the token costs one line per handler.
Two exceptions catch people out. Sign in with Apple posts its response back to your site cross-site, so a SameSite=Lax session cookie won't arrive on that callback; verify that request with the provider's signed token and a short-lived state value instead. And payment webhooks come from the provider's servers with no cookies at all, so they are verified by signature, never by CSRF token. Our Apple and Google sign-in guide and Stripe webhooks guide cover both.
Sources: Stack Overflow: How to properly add cross-site request forgery (CSRF) token using PHP · Stack Overflow: Is this sufficient to protect against a CSRF for an AJAX-driven application? · OWASP CSRF Prevention Cheat Sheet · PHP manual: session security · MDN: SameSite cookies
How Simple Stack handles it
Simple Stack does all of this in app/bootstrap.php, and every state-changing browser action in public/api.php calls verify_csrf() first. SECURITY.md lists the settings to check before launch.
- A 32-byte session token from random_bytes, checked with hash_equals; a mismatch returns 419
- The included jQuery client sends X-CSRF-Token on every mutating request
- Session cookie is HttpOnly, SameSite=Lax and Secure over HTTPS, with strict mode on
- session_regenerate_id(true) runs at sign-in, so a planted session ID is useless
- Sign-out carries the session token, so another site can't force a logout
- SESSION_COOKIE in .env gives each product its own cookie name
Questions people ask
Skip the plumbing. Build the product.
Sign-in, Stripe, emails, an admin and a CMS — already built and tested in plain PHP. £49, once, for unlimited projects.