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.
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 tokeninput(context, fieldName = "_csrf")
- hidden fieldvalidate(token, context)
- constant-time comparevalidateRequest(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 hardening →
HttpOnly
,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)
- 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"
. - Lookup - server checks session store for a matching, unused, unexpired token that belongs to the provided context.
- Compare -
hash_equals()
provides constant-time comparison of the candidate and stored token. - 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 needStrict
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 onPOST/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 onbilling
) → 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.