Secure your application using CSRF Tokens. Create CSRF Library in PHP

Kamil Pasik

Secure your application using CSRF Tokens. Create CSRF Library in PHP

Build a clean CSRF library in PHP and integrate it in both forms and AJAX.
This article walks through what CSRF is, how attackers abuse stateful web apps, and how to implement robust mitigation with one-time, session-backed tokens that work for HTML forms and fetch() JSON calls.

Security rule of thumb: make the secure path the easy path. The API should nudge developers toward correct usage by default.

CSRF demo form and AJAX example


What is CSRF (and why you should care)

Cross-Site Request Forgery tricks a victim’s browser into sending an authenticated request to your site without their intention. If your app relies only on cookies for authentication, a third-party page can cause the browser to submit a form or call an endpoint with your session attached. Without an additional, unguessable verifier, your server can’t tell a forged request from a legitimate one.

Typical impact

  • state-changing POSTs: changing email, password, shipping address
  • silent actions: creating orders, posting messages, deleting data
  • chained with XSS it gets worse, but CSRF is solvable without JS

Key property: the attacker can send cookies, but cannot read your pages (Same-Origin Policy). That’s why the fix is to require a secret the attacker can’t obtain: a per-request token.


Design we’ll implement

  • Session-backed tokens (server knows valid tokens).
  • One-time tokens - consumed on success to block replay.
  • TTL (time-to-live) to expire stale tokens.
  • Per-context scoping (different token per form/route).
  • Validation for: HTML forms (hidden input), fetch headers, and JSON bodies.
  • Minimal API:
    • generate(context, ttl?) - mint token
    • input(context, fieldName = "_csrf") - hidden field
    • validate(token, context) - constant-time compare
    • validateRequest(context, fieldName?) - reads from POST/header/JSON

We also add light garbage collection and limits to avoid session bloat.


Folder layout

/ (public root)
├─ index.php        # demo form (HTML) + page that renders token for AJAX
├─ contact.php      # JSON endpoint that validates AJAX token
├─ CSRF.php         # the library (session-based, one-time tokens)
├─ app.js           # tiny demo script that calls contact.php with fetch()
└─ main.css         # styles for the demo

The CSRF class

Below is a placeholder for the full class. Paste your implementation in this block.
(Use the version from the repository or from the code sample in this article.)

<?php

declare(strict_types=1);

namespace App\Security;

/**
 * CSRF protection (session-based, one-time tokens)
 * PHP 8.1+
 */
final class CSRF
{
    private const SESSION_KEY = '__csrf_tokens';
    private const DEFAULT_FIELD = '_csrf';
    private int $maxTokens;
    private int $defaultTtl;

    public function __construct(int $maxTokens = 20, int $defaultTtlSeconds = 900)
    {
        $this->ensureSession();
        $this->maxTokens  = max(1, $maxTokens);
        $this->defaultTtl = max(1, $defaultTtlSeconds);
        $this->initStore();
        $this->gc();
    }

    /**
     * Generate a new one-time token for a given context (form name/route).
     */
    public function generate(string $context, ?int $ttlSeconds = null): string
    {
        $ttl = $ttlSeconds ?? $this->defaultTtl;
        $token = bin2hex(random_bytes(32)); // 256-bit
        $now = time();

        $_SESSION[self::SESSION_KEY][] = [
            'token'   => $token,
            'ctx'     => $context,
            'exp'     => $now + $ttl,
            'created' => $now,
            'used'    => false,
        ];

        // keep most recent N tokens
        usort($_SESSION[self::SESSION_KEY], fn($a, $b) => $b['created'] <=> $a['created']);
        $_SESSION[self::SESSION_KEY] = array_slice($_SESSION[self::SESSION_KEY], 0, $this->maxTokens);

        return $token;
    }

    /**
     * Return ready-to-use hidden input HTML.
     */
    public function input(string $context, string $fieldName = self::DEFAULT_FIELD, ?int $ttlSeconds = null): string
    {
        $token = $this->generate($context, $ttlSeconds);
        $name  = htmlspecialchars($fieldName, ENT_QUOTES, 'UTF-8');
        $value = htmlspecialchars($token, ENT_QUOTES, 'UTF-8');

        return sprintf('<input type="hidden" name="%s" value="%s">', $name, $value);
    }

    /**
     * Validate a token (POST form field or header). Consumes token on success.
     */
    public function validate(string $token, string $context): bool
    {
        $this->gc();
        $store = &$_SESSION[self::SESSION_KEY];

        foreach ($store as $i => $item) {
            if ($item['ctx'] !== $context || $item['used'] === true) {
                continue;
            }
            if ($item['exp'] > 0 && time() > $item['exp']) {
                // expired — drop it
                unset($store[$i]);
                continue;
            }
            if (hash_equals($item['token'], $token)) {
                // one-time: mark used & drop
                unset($store[$i]);
                return true;
            }
        }
        return false;
    }

    /**
     * Validate from superglobals (form field or header).
     */
    public function validateRequest(string $context, string $fieldName = self::DEFAULT_FIELD): bool
    {
        $token = null;

        // 1) Form field
        if (isset($_POST[$fieldName]) && is_string($_POST[$fieldName])) {
            $token = $_POST[$fieldName];
        }

        // 2) AJAX / fetch header: X-CSRF-Token
        if ($token === null) {
            $headerName = 'HTTP_X_CSRF_TOKEN';
            if (isset($_SERVER[$headerName]) && is_string($_SERVER[$headerName])) {
                $token = $_SERVER[$headerName];
            }
        }

        // 3) JSON body: { "_csrf": "..." }
        if ($token === null && isset($_SERVER['CONTENT_TYPE']) && str_contains($_SERVER['CONTENT_TYPE'], 'application/json')) {
            $raw = file_get_contents('php://input');
            if ($raw !== false && $raw !== '') {
                $json = json_decode($raw, true);
                if (is_array($json) && isset($json[$fieldName]) && is_string($json[$fieldName])) {
                    $token = $json[$fieldName];
                }
            }
        }

        if (!is_string($token) || $token === '') {
            return false;
        }
        return $this->validate($token, $context);
    }

    private function ensureSession(): void
    {
        if (session_status() !== PHP_SESSION_ACTIVE) {
            // In production, session cookies should be secure/samesite strict:
            ini_set('session.cookie_httponly', '1');
            ini_set('session.cookie_secure', isset($_SERVER['HTTPS']) ? '1' : '0');
            ini_set('session.cookie_samesite', 'Lax');
            session_start();
        }
    }

    private function initStore(): void
    {
        if (!isset($_SESSION[self::SESSION_KEY]) || !is_array($_SESSION[self::SESSION_KEY])) {
            $_SESSION[self::SESSION_KEY] = [];
        }
    }

    private function gc(): void
    {
        $now = time();
        $_SESSION[self::SESSION_KEY] = array_values(array_filter(
            $_SESSION[self::SESSION_KEY],
            fn($t) => ($t['exp'] === 0 || $t['exp'] > $now) && !$t['used']
        ));
    }
}

Why it’s designed this way

  • random_bytes(32) → 256-bit tokens, cryptographically strong, safe to render in HTML.
  • hash_equals() → timing-attack-safe comparison.
  • Per-context tokens → a token generated for contact-form can’t be replayed on /billing.
  • One-time consumption → successful validation removes the token, preventing replay.
  • TTL & GC → expired tokens are pruned to keep sessions small.
  • Session cookie hardeningHttpOnly, Secure (on HTTPS), SameSite=Lax by default.

CSRF protection complements, not replaces, SameSite cookies and CORS. Use both.


Server endpoints

1) HTML form page (index.php)

Renders a form with a hidden CSRF input and exposes a one-time meta token for AJAX.

<?php

declare(strict_types=1);

use App\Security\CSRF;

require __DIR__ . '/CSRF.php';

$csrf = new CSRF(maxTokens: 32, defaultTtlSeconds: 900);
$context = 'contact-form';
$apiContext = 'api-contact'; // for AJAX

$isPost = ($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST';
$ok = null;

if ($isPost) {
    $ok = $csrf->validateRequest($context);
    if ($ok) {
        $message = 'CSRF OK — form submitted.';
    } else {
        http_response_code(403);
        $message = 'CSRF validation failed.';
    }
}
?>
<!doctype html>
<html lang="en">

<head>
    <meta charset="utf-8">
    <title>CSRF Demo</title>
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <meta name="csrf-token" content="<?= htmlspecialchars($csrf->generate($apiContext, 900), ENT_QUOTES, 'UTF-8') ?>">
    <link rel="stylesheet" href="main.css">
    <script src="app.js" defer></script>
</head>

<body>
    <div class="container page">
        <section class="card">
            <h1>Secure your application with CSRF tokens</h1>
            <p class="muted">One-time session token, per form context, with TTL and request validation (POST field / header / JSON).</p>

            <?php if ($ok !== null): ?>
                <div class="alert <?= $ok ? 'ok' : 'err' ?>">
                    <?= htmlspecialchars($message, ENT_QUOTES, 'UTF-8') ?>
                </div>
            <?php endif; ?>

            <form method="post" action="">
                <label>
                    Your email
                    <input type="email" name="email" value="mail@example.com" required>
                </label>
                <label>
                    Message
                    <textarea name="message" rows="4" required>Hi!</textarea>
                </label>

                <?= $csrf->input($context) ?>

                <div class="actions">
                    <button type="submit">Send</button>
                    <button type="button" id="ajax-test" class="secondary">Test AJAX CSRF</button>
                    <span class="muted">AJAX token is one-time. Use once, then it’s consumed.</span>
                </div>
            </form>
        </section>

        <section class="card">
            <h2>AJAX example (what the JS sends)</h2>
            <pre><code>fetch('/contact.php', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': '<one-time-token>' },
  body: JSON.stringify({ email: 'mail@example.com', message: 'Hi!' })
})</code></pre>
        </section>
    </div>
</body>

</html>

2) JSON endpoint (contact.php)

Validates tokens coming from either header (X-CSRF-Token) or JSON body ({ "_csrf": "..." }).

<?php

declare(strict_types=1);

use App\Security\CSRF;

require __DIR__ . '/CSRF.php';

header('Content-Type: application/json; charset=utf-8');

$csrf = new CSRF();
$context = 'api-contact';

if (!$csrf->validateRequest($context)) {
    http_response_code(403);
    echo json_encode(['ok' => false, 'error' => 'CSRF failed']);
    exit;
}

echo json_encode(['ok' => true]);

Front-end integration

A tiny fetch() example that reads the token from <meta name="csrf-token">, sends it in X-CSRF-Token, and consumes the token after success to respect the one-time contract.

;(function () {
  const btn = document.getElementById('ajax-test')
  if (!btn) return

  const readToken = () => {
    const m = document.querySelector('meta[name="csrf-token"]')
    return m?.getAttribute('content') || ''
  }

  const consumeToken = () => {
    const m = document.querySelector('meta[name="csrf-token"]')
    if (m) m.setAttribute('content', '')
  }

  btn.addEventListener('click', async () => {
    const token = readToken()
    if (!token) {
      console.warn(
        'No CSRF token available (probably already used). Reload the page to get a fresh token.'
      )
      return
    }

    try {
      const res = await fetch('/contact.php', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
          'X-CSRF-Token': token,
        },
        body: JSON.stringify({
          email: 'test@example.com',
          message: 'CSRF AJAX test',
        }),
      })

      const data = await res.json().catch(() => ({}))
      console.log('Response status:', res.status)
      console.log('Response JSON:', data)
      console.log('CSRF OK?', data && data.ok === true)

      consumeToken()
      btn.disabled = true
      btn.textContent = 'AJAX tested (reload to test again)'
    } catch (err) {
      console.error('AJAX request failed:', err)
    }
  })
})()

Important UX note: one-time means you should mint a new token on every render or expose a light “get fresh token” endpoint when building SPA flows.

Styles:

:root {
  --gap: 1rem;
  --gap-lg: 1.25rem;
  --fg: #0f172a;
  --muted: #64748b;
  --ok: #16a34a;
  --err: #dc2626;
  --surface: #ffffff;
  --surface-alt: #f8fafc;
  --border: #e2e8f0;
  --accent: #0ea5e9;
  --radius: 10px;
  --shadow: 0 10px 25px rgba(0, 0, 0, 0.06);
}

* {
  box-sizing: border-box;
}

html,
body {
  height: 100%;
}

body {
  font-family: system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial,
    sans-serif;
  color: var(--fg);
  margin: 0;
  background: var(--surface-alt);
  line-height: 1.65;
}

.container {
  max-width: 880px;
  margin: 0 auto;
  padding: 2rem 1.25rem;
}

h1,
h2 {
  line-height: 1.25;
  margin: 0 0 0.75rem 0;
}
h1 {
  font-size: clamp(1.6rem, 1.2rem + 2vw, 2.2rem);
}
h2 {
  font-size: clamp(1.2rem, 1rem + 1.2vw, 1.6rem);
}

.muted {
  color: var(--muted);
  font-size: 0.95rem;
}

section.card {
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  box-shadow: var(--shadow);
  padding: 1.25rem;
}

form {
  display: grid;
  gap: var(--gap-lg);
  max-width: 560px;
}

label {
  display: grid;
  gap: 0.5rem;
  font-weight: 600;
}

input,
textarea {
  font: inherit;
  padding: 0.7rem 0.8rem;
  border-radius: 0.6rem;
  border: 1px solid var(--border);
  background: #fff;
  outline: none;
}

input:focus,
textarea:focus {
  border-color: color-mix(in srgb, var(--accent) 50%, var(--border));
  box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 20%, transparent);
}

button {
  font: inherit;
  padding: 0.75rem 1rem;
  border-radius: 0.6rem;
  border: 1px solid transparent;
  background: var(--accent);
  color: #fff;
  cursor: pointer;
  transition: transform 0.05s ease, filter 0.15s ease, background 0.15s ease;
}
button:hover {
  filter: brightness(1.05);
}
button:active {
  transform: translateY(1px);
}
button.secondary {
  background: #fff;
  color: var(--fg);
  border-color: var(--border);
}

.alert {
  margin: 0.75rem 0 1rem;
  padding: 0.75rem 1rem;
  border-radius: 0.6rem;
  border: 1px solid;
}
.ok {
  background: #ecfdf5;
  color: #065f46;
  border-color: #a7f3d0;
}
.err {
  background: #fef2f2;
  color: #991b1b;
  border-color: #fecaca;
}

code {
  background: #f1f5f9;
  padding: 0.12em 0.4em;
  border-radius: 0.35rem;
}
pre {
  background: #0b1020;
  color: #e6edf3;
  border: 1px solid rgba(255, 255, 255, 0.08);
  border-radius: 0.7rem;
  padding: 1rem 1.25rem;
  overflow: auto;
  box-shadow: var(--shadow);
}
pre code {
  background: transparent;
  padding: 0;
}

p code:not(pre code),
li code:not(pre code) {
  white-space: normal;
  overflow-wrap: anywhere;
  word-break: break-word;
}

.page {
  display: grid;
  gap: 2rem;
}
.actions {
  display: flex;
  gap: 0.75rem;
  flex-wrap: wrap;
  align-items: center;
}

@media (max-width: 640px) {
  .container {
    padding: 1.5rem 1rem;
  }
}

How validation works (deep dive)

  1. Extraction - validateRequest() looks for a token in three places in order:
    a) POST field named "_csrf" (customizable),
    b) header "X-CSRF-Token",
    c) JSON body field "_csrf".
  2. Lookup - server checks session store for a matching, unused, unexpired token that belongs to the provided context.
  3. Compare - hash_equals() provides constant-time comparison of the candidate and stored token.
  4. Consume - on success the token is removed from the session to prevent replay.

This defends against classic CSRF because the attacker can’t read your pages to steal the token, and can’t guess it.


Threat model notes

  • HTTPS only: CSRF tokens travel in the DOM and headers; always use TLS and set Secure session cookies.
  • SameSite: Lax protects against most top-level navigations; complex apps might still need Strict for sensitive areas.
  • XSS: if an attacker gets XSS, they can read tokens. CSRF does not mitigate XSS. Keep CSP and output escaping in place.
  • Idempotent GET: never change state on GET. CSRF targets state changes - keep them on POST/PUT/PATCH/DELETE.
  • Replays: one-time tokens plus TTL shut down replays and token harvesting.

Testing checklist

  • Form submit with valid token → 200 / success.
  • Form submit with missing/empty token → 403.
  • Form submit with reused token (double submit) → 403.
  • AJAX with header token → 200 / success; repeat same token → 403.
  • Token older than TTL → 403.
  • Wrong context (e.g., token for contact-form used on billing) → 403.

Production tips

  • Rate-limit sensitive endpoints; CSRF is not a rate limiter.
  • Rotate session IDs on auth changes; expired sessions invalidate token stores.
  • Log failures (without tokens) to spot anomalies.
  • Expose helper for frameworks (middleware / controller trait) to centralize checks.
  • Consider double-submit cookie if you run behind services that strip headers or can’t use sessions.

Wrap-up

You now own a small but battle-ready CSRF layer: per-context, one-time, with TTL and clear ergonomics for forms and AJAX. The goal is simple: secure defaults that are easy to use.