CSRF Protection in PHP: Tokens, SameSite Cookies & Best Practices
Cross-Site Request Forgery (CSRF) tricks a user's browser into submitting a form to your site using their existing session. The attacker crafts a page with a hidden form pointing at your endpoint, and when the victim visits it, their browser sends the request with their cookies automatically. Your server can't tell the difference between a legitimate submission and the forged one.
CSRF protection is not spam protection — it won't stop bots. But it's essential security for any PHP form that modifies data. Here's how to implement it properly.
The Three Approaches
| Method | Protection Level | Requires Sessions | Works with CDN Cache | Best For |
|---|---|---|---|---|
| Synchronizer Token | Strong | Yes | No (dynamic per-page) | Traditional PHP forms |
| Double-Submit Cookie | Strong | No | Yes | Stateless/cached pages, APIs |
| SameSite Cookie Attribute | Moderate (defense-in-depth) | No | Yes | Additional layer on all approaches |
Method 1: Synchronizer Token Pattern
The classic approach: generate a random token, store it in the session, embed it in the form, and verify it on submission. This is what most PHP frameworks implement internally.
<?php
// CSRF synchronizer token — PHP 8.0+
session_start();
function generateCsrfToken(): string
{
$token = bin2hex(random_bytes(32));
$_SESSION['csrf_token'] = $token;
$_SESSION['csrf_token_time'] = time();
return $token;
}
function verifyCsrfToken(string $submitted, int $maxAge = 3600): bool
{
$expected = $_SESSION['csrf_token'] ?? '';
$created = $_SESSION['csrf_token_time'] ?? 0;
// For high-security actions, clear after use (single-use):
// unset($_SESSION['csrf_token'], $_SESSION['csrf_token_time']);
// For standard forms, keep the token valid for the session duration
if ($expected === '' || $submitted === '') {
return false;
}
// Check expiration
if (time() - $created > $maxAge) {
return false;
}
// Timing-safe comparison prevents timing attacks
return hash_equals($expected, $submitted);
}
In Your Form
<form method="POST" action="/update-profile.php">
<input type="hidden" name="_csrf" value="<?= htmlspecialchars(generateCsrfToken()) ?>">
<input type="text" name="display_name" required>
<button type="submit">Update</button>
</form>
In Your Handler
<?php
session_start();
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (!verifyCsrfToken($_POST['_csrf'] ?? '')) {
http_response_code(403);
$errors[] = 'Invalid or expired security token. Please reload the form.';
} else {
// Safe to process the form
}
}
Multi-tab problem: If a user opens your form in two tabs, the second tab's token overwrites the first tab's token in the session. Submitting the first tab fails. Fix this with per-form tokens:
<?php
// Per-form CSRF tokens — supports multiple tabs
function generateCsrfTokenForForm(string $formId): string
{
$token = bin2hex(random_bytes(32));
$_SESSION['csrf_tokens'][$formId] = [
'token' => $token,
'time' => time(),
];
return $token;
}
function verifyCsrfTokenForForm(string $formId, string $submitted, int $maxAge = 3600): bool
{
$stored = $_SESSION['csrf_tokens'][$formId] ?? null;
unset($_SESSION['csrf_tokens'][$formId]);
if ($stored === null || $submitted === '') {
return false;
}
if (time() - $stored['time'] > $maxAge) {
return false;
}
return hash_equals($stored['token'], $submitted);
}
Session cleanup: Limit stored tokens to prevent session bloat. Keep only the 10 most recent form tokens and discard older ones.
Method 2: HMAC Double-Submit Cookie (Stateless)
When you can't use sessions (cached pages, stateless APIs, microservices), use the double-submit cookie pattern with HMAC signing. The server sets a cookie and embeds the same value in the form. On submission, it verifies they match — something a cross-site attacker can't read or forge.
<?php
// HMAC double-submit CSRF — no session required
define('CSRF_SECRET', getenv('CSRF_SECRET') ?: 'change-this-32-char-minimum-key!');
// IMPORTANT: Call this function at the top of your script, BEFORE any HTML output.
// setcookie() sends HTTP headers, which must come before the response body.
// Store the return value and use it later in your form.
function generateDoubleSubmitToken(): string
{
$nonce = bin2hex(random_bytes(16));
$time = (string) time();
$sig = hash_hmac('sha256', "{$nonce}:{$time}", CSRF_SECRET);
$token = "{$nonce}:{$time}:{$sig}";
// Set as a cookie (SameSite=Strict for extra protection)
setcookie('csrf_ds', $token, [
'expires' => time() + 3600,
'path' => '/',
'secure' => true,
'httponly' => true,
'samesite' => 'Strict',
]);
return $token;
}
function verifyDoubleSubmitToken(string $formToken, int $maxAge = 3600): bool
{
$cookieToken = $_COOKIE['csrf_ds'] ?? '';
if ($formToken === '' || $cookieToken === '') {
return false;
}
// Tokens must match
if (!hash_equals($cookieToken, $formToken)) {
return false;
}
// Verify HMAC signature
$parts = explode(':', $formToken, 3);
if (count($parts) !== 3) {
return false;
}
[$nonce, $time, $sig] = $parts;
if (time() - (int) $time > $maxAge) {
return false;
}
$expectedSig = hash_hmac('sha256', "{$nonce}:{$time}", CSRF_SECRET);
return hash_equals($expectedSig, $sig);
}
Why HMAC matters: A plain double-submit cookie (without HMAC) is vulnerable to cookie injection attacks if an attacker can set cookies on a subdomain. The HMAC signature proves the token was generated by your server.
Method 3: SameSite Cookie Attribute
SameSite tells browsers not to send cookies on cross-site requests. It's not a complete CSRF defense on its own, but it blocks the most common attack vector.
<?php
// Set SameSite on your session cookie — add to the top of your application
ini_set('session.cookie_samesite', 'Lax');
ini_set('session.cookie_secure', '1');
ini_set('session.cookie_httponly', '1');
session_start();
// Or configure in php.ini:
// session.cookie_samesite = Lax
// session.cookie_secure = 1
// session.cookie_httponly = 1
Lax vs Strict:
- Lax (recommended default): Allows cookies on top-level GET navigations (clicking a link to your site). Blocks cookies on cross-site POST, iframe, and AJAX requests. Works for most sites.
- Strict: Blocks cookies on all cross-site requests, including link clicks. Breaks OAuth flows and any feature that relies on incoming links maintaining session state.
Not a standalone defense: SameSite Lax doesn't prevent CSRF on GET endpoints (which shouldn't modify data anyway) and has inconsistent support in older browsers. Always pair it with token-based protection.
CSRF vs Spam: Different Problems
A common misconception: CSRF tokens stop spam. They don't. CSRF protection verifies that the request came from your form. A bot that loads your form page, extracts the CSRF token, and submits it has a perfectly valid token.
- CSRF stops: Cross-site request forgery (an attacker's page submitting to your endpoint using the victim's session)
- CSRF doesn't stop: Bot spam, credential stuffing, automated form submissions from scripts that first load your page
For spam protection, you need honeypot fields, rate limiting, or a CAPTCHA. Include CSRF tokens for security, but don't count them as part of your anti-spam strategy.
Using a Framework?
If you're using Laravel, Symfony, or another PHP framework, CSRF protection is built in. Laravel provides @csrf in Blade templates. Symfony's Form component includes CSRF tokens automatically. Use these instead of rolling your own — they handle edge cases (session expiry, multi-tab, AJAX) that vanilla implementations often miss.
Common Mistakes
Not Using hash_equals()
Standard string comparison (===) leaks timing information. An attacker can determine how many characters match by measuring response time. Always use hash_equals() for token comparison.
Reusing Tokens Across Submissions
Single-use tokens provide maximum replay-attack protection but break when form validation fails — the user resubmits but the token was already consumed. For most PHP applications, session-scoped tokens (valid for the entire session, not single-use) are the practical choice. Reserve single-use tokens for high-security actions like password changes or account deletion.
GET Requests That Modify Data
If /delete-account?confirm=yes works via GET, SameSite Lax doesn't protect it. State-changing operations must use POST, PUT, or DELETE — never GET.
Forgetting AJAX
For AJAX form submissions, include the CSRF token as a request header rather than a form field:
<?php
// Server-side: check header or form field
$csrfToken = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? $_POST['_csrf'] ?? '';
// Client-side: send token in header
fetch('/api/update', {
method: 'POST',
headers: { 'X-CSRF-Token': document.querySelector('[name=_csrf]').value },
body: formData,
});
For single-page applications or pages with multiple AJAX endpoints, embed the token in a meta tag:
<meta name="csrf-token" content="<?= htmlspecialchars($csrfToken) ?>">
// Global interceptor — adds token to all fetch requests
const csrfToken = document.querySelector('meta[name="csrf-token"]').content;
Verdict
Use the synchronizer token pattern for standard PHP forms — it's simple, proven, and what every major framework implements. Add SameSite=Lax on all session cookies as defense-in-depth. Switch to HMAC double-submit only if you need stateless/cached page support.
And remember: CSRF protection secures your forms against forgery attacks. For the separate problem of bot spam, see our PHP form spam protection guide.