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:

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.

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.