PHP cookbook · Beginner

Add CSRF protection to PHP forms

Prevent cross-site request forgery on session-authenticated forms without a framework.

Approach

Generate a random token per session, embed it in every state-changing form (and an X-CSRF-Token header for AJAX), and verify it with hash_equals() on POST/PUT/PATCH/DELETE. Combine with SameSite=Lax session cookies and never change state on GET.

Solution

<?php
declare(strict_types=1);

session_start();

function csrf_token(): string
{
    return $_SESSION['_csrf'] ??= bin2hex(random_bytes(32));
}

function verify_csrf(): void
{
    if (in_array($_SERVER['REQUEST_METHOD'], ['GET', 'HEAD', 'OPTIONS'], true)) {
        return;
    }
    $token = $_POST['_csrf'] ?? $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
    if (!is_string($token) || !hash_equals($_SESSION['_csrf'] ?? '', $token)) {
        http_response_code(419);
        exit('Page expired. Please reload and try again.');
    }
}

verify_csrf();
?>
<form method="post" action="/profile">
  <input type="hidden" name="_csrf" value="<?= htmlspecialchars(csrf_token(), ENT_QUOTES) ?>">
  <input name="display_name">
  <button>Save</button>
</form>
<meta name="csrf-token" content="<?= htmlspecialchars(csrf_token(), ENT_QUOTES) ?>">

Pitfalls

  • Comparing tokens with == or === (timing leaks) instead of hash_equals.
  • Exposing tokens in URLs (logs, Referer).
  • Skipping CSRF checks for AJAX endpoints that use cookies.

Variations

  • Per-form tokens for extra-sensitive actions.
  • Frameworks: Laravel's @csrf, Symfony's csrf_token() — keep them enabled.

Further reading: OWASP CSRF Prevention Cheat Sheet

Reference: https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html

More PHP recipes