rafalmasiarek / csrf-token
Encrypted CSRF token library with fingerprint and caching (file, MySQL, Redis)
Requires
- php: >=8.2
Requires (Dev)
- phpunit/phpunit: ^10.0
- squizlabs/php_codesniffer: ^3.9
Suggests
None
Provides
None
Conflicts
None
Replaces
None
- dev-main
- v1.6.0
- v1.5.1
- v1.5.0
- v1.4.0
- v1.3.4
- v1.3.3
- v1.3.2
- 1.3.0
- v1.2.1
- v1.2.0
- v1.1.0
- v1.0.0
- dev-feat/container-defaults
- dev-chore/release-1.5.1
- dev-feat/html-helper-ip-override
- dev-chore/changelog-1.5.0
- dev-feat/csrf-origin-payload-binding
- dev-v1.3.1-refactor-examples-and-create-debug-mode
- dev-feature/7-add-ip-and-user-agent-resolver-interface
- dev-release-v1.3.0
- dev-release-v1.2.0
This package is auto-updated.
Last update: 2026-09-16 19:31:45 UTC
README
A secure CSRF protection library with AES‑256‑GCM encryption and per‑form containers. Each form (container) has its own session bucket, derived key (HKDF) and optional pepper. Tokens are bound to container id, client IP, User‑Agent, and origin (all configurable), with an optional session-bound HMAC proof as a second, independent field.
Features
- Stateless encrypted token (payload: token, ip, ua, origin, iat, container id)
- AES‑256‑GCM with IV + auth tag, AAD binds to container id/prefix
- Per‑container isolation: replay across forms is impossible
- Configurable bindings (IP/UA/origin) per container, plus a separate
allowed_originswhitelist - Optional session-bound HMAC proof (
_csrf_proof), key-separated from the AES-GCM key (HKDF) - Tokens are always single-use (consumed on successful validation, not configurable)
- TTL support (set
0to disable expiry — not recommended) - Optional cache layer (File / MySQL / Redis) for fast‑path validation
- Backwards compatible API:
generate()/validate()still work for the"default"container
Install
composer require rafalmasiarek/php-csrf
Quick Start
use rafalmasiarek\Csrf\Csrf; session_start(); $csrf = new Csrf($masterKey32Bytes, 900); // TTL=900s $token = $csrf->generate(); // default container // <input type="hidden" name="_csrf" value="$token">
Validation:
if (!$csrf->validate($_POST['_csrf'] ?? null)) { http_response_code(419); exit('CSRF verification failed'); }
Containers (Multiple Forms)
use rafalmasiarek\Csrf\{Csrf, CsrfCacheWrapper, FileStorage}; $csrf = (new Csrf($masterKey32Bytes, 900)) ->withContainer('signup', ['prefix' => 'auth_', 'bind_ip' => true, 'bind_ua' => true]) ->withContainer('profile', ['prefix' => 'user_', 'bind_ip' => false, 'bind_ua' => true]); $cache = new FileStorage(__DIR__ . '/var/csrf-cache'); // or MysqlStorage / RedisStorage $csrfCached = new CsrfCacheWrapper($csrf, $cache); // View: $tokenSignup = $csrfCached->generateFor('signup'); $tokenProfile = $csrfCached->generateFor('profile'); // POST handler: $ok = $csrfCached->validateFor('signup', $_POST['_csrf'] ?? '');
Storage Options
FileStorage($dir)— writes JSON files (hashed filenames)MysqlStorage(PDO $pdo)— uses tablecsrf_cache(token_hash, payload JSON, created_at)RedisStorage(Redis $redis, string $prefix = 'csrf:', int $ttl = 900)
Storage keys are hashed; for multi‑containers we pass a composite
"<container>|<token>"to avoid collisions.
Security Notes
- Keep the master key (32 bytes) secret. Rotate periodically.
- Prefer enabling IP/UA binding. Disable only if your environment makes them unstable.
- Set a reasonable TTL.
0(no expiry) is supported but not recommended. - Tokens are burned after successful validation (per container).
Client Context Provider (IP / User-Agent injection)
Since version 1.3.2 the library supports external injection of client IP and User-Agent, instead of relying solely on $_SERVER.
Passing IP/UA explicitly
$token = $csrf->generate($realIp, $realUa); $isValid = $csrf->validate($submittedToken, $realIp, $realUa);
Container-specific:
$token = $csrf->generateFor('signup', $realIp, $realUa); $ok = $csrf->validateFor('signup', $submittedToken, $realIp, $realUa);
ClientContextProviderInterface
use rafalmasiarek\Csrf\ClientContextProviderInterface; class SlimRequestProvider implements ClientContextProviderInterface { public function __construct(private \Psr\Http\Message\ServerRequestInterface $request) {} public function getIp(): string { return $this->request->getAttribute('client_ip') ?? ''; } public function getUserAgent(): string { return $this->request->getHeaderLine('User-Agent'); } }
Usage:
$csrf = new Csrf($key, 900, new SlimRequestProvider($request));
Origin Binding
Independent from IP/UA binding: when a container has a trusted origin configured, that
origin is embedded in the encrypted payload at generation time and enforced against the
resolved request Origin at validation time. Unlike allowed_origins (below), this binds
one specific token instance to one specific origin, not a request to a whitelist.
$csrf = (new Csrf($masterKey32Bytes, 900)) ->withContainer('transfer-funds', [ 'bind_ip' => false, 'bind_ua' => true, 'origin' => 'https://app.example.com', // trusted, static — never from a request header ]); $token = $csrf->generateFor('transfer-funds'); // throws \RuntimeException at generation time if 'origin' is set but does not // normalize to a valid http(s) origin (fail loud on misconfiguration) $ok = $csrf->validateFor( 'transfer-funds', $_POST['_csrf'] ?? null, null, null, $_SERVER['HTTP_ORIGIN'] ?? null // or omit — resolved automatically via OriginProviderInterface );
Rules:
- Origins are normalized before comparison: lowercase scheme/host, no trailing slash, default
ports (
443forhttps,80forhttp) omitted.https://APP.example.com:443/andhttps://app.example.comare equal;https://app.example.com:8443is not. Subdomains are distinct origins (https://api.example.com≠https://example.com). - A missing request origin is always rejected when binding is active (fail-closed) — there is no lenient mode for this check.
- Comparison is constant-time (
hash_equals). - Leaving
originunset (defaultnull) disables the check entirely; it never affects containers that don't configure it.
Origin Scope (allowed_origins)
A separate, coarser mechanism: a per-container whitelist checked independently of any single token, useful when multiple trusted origins must be accepted (e.g. web + mobile webview).
$csrf = (new Csrf($masterKey32Bytes, 900)) ->withContainer('checkout', [ 'allowed_origins' => ['https://app.example.com', '*.example.com'], 'origin_mode' => 'strict', // 'strict': missing origin = reject; 'lenient' (default): missing origin = accept ]);
Supports exact origins, scheme-free hosts (example.com matches any scheme), and single-label
wildcards (*.example.com matches https://api.example.com, not https://a.b.example.com).
Run both mechanisms together when you want a specific token bound to a specific origin and a
broader fallback whitelist for multi-origin deployments.
Session-Bound Proof (_csrf_proof)
An optional second field, _csrf_proof, provides independent evidence that the submitted
_csrf token belongs to the current authenticated session — not just that it decrypts
correctly. It is an HMAC-SHA256 over the exact token, the container id, and a session-bound
value, keyed by a proof key derived independently from the AES-GCM encryption key (HKDF
domain separation: csrf:<container> vs. csrf-proof:<container>). This borrows the
session-binding principle from OWASP's Signed Double-Submit Cookie guidance; it is not a
literal double-submit-cookie implementation — no cookie is involved, the library remains a
synchronizer-token design.
$csrf = (new Csrf($masterKey32Bytes, 900)) ->withContainer('transfer-funds', ['require_proof' => true]); // Issue token + proof atomically — never mix a token from one pair with a proof from another: $pair = $csrf->issueFor('transfer-funds'); // <input type="hidden" name="_csrf" value="<?= $pair->token ?>"> // <input type="hidden" name="_csrf_proof" value="<?= $pair->proof ?>"> // Validate — proof is transparent by default: $ok = $csrf->validateFor( 'transfer-funds', $_POST['_csrf'] ?? null, null, null, null, $_POST['_csrf_proof'] ?? null );
Behavior:
- Transparent: a submitted proof is always verified (mismatch → reject); a missing proof
is only rejected when the container sets
'require_proof' => true(defaultfalse). issueFor()always computes a proof when a session is active;generateFor()never does (no behavior change for existing callers).- The proof is bound to the exact encrypted token string. Regenerating the token via
generateFor()/issueFor()invalidates any previously issued proof, and vice versa — always refresh both together. - The session binding comes from
SessionBindingProviderInterface::getSessionBinding()(default: nativesession_id()). Provide a custom implementation for framework-managed or JWT-backed sessions:
use rafalmasiarek\Csrf\SessionBindingProviderInterface; final class JwtSessionBindingProvider implements SessionBindingProviderInterface { public function __construct(private string $jwtSessionClaim) {} public function getSessionBinding(): string { return $this->jwtSessionClaim; // must change on login/logout/privilege elevation } } $csrf = new Csrf($masterKey32Bytes, 900, null, new JwtSessionBindingProvider($claim));
Never bind to a static account attribute (user id, email) — the value must change whenever the login session is regenerated, or a stolen token/proof pair from a previous session stays valid across logins.
Validation Order
missing _csrf -> reject
_csrf_proof (if submitted, or required) -> reject on mismatch/missing-when-required
allowed_origins whitelist -> reject
AES-GCM decrypt/authenticate -> reject on tamper
container id match -> reject
server-side session state -> reject if absent
IP / User-Agent binding -> reject on mismatch
origin binding -> reject on mismatch
expiry -> reject if expired
-> consume token (single-use), accept
Token Lifecycle: Single-Use, Back Button, and Refreshing
Tokens are always single-use: validateFor() burns the server-side session state on
success. This is a hard-coded property of the library, not a configuration flag — plan the
UX around it rather than looking for a way to disable it.
The Back button / BFCache problem
GET /checkout -> server issues token T1
POST /checkout -> T1 validated, consumed
user presses Back -> browser may restore the /checkout page from BFCache
(the form still shows the now-consumed T1)
user re-submits -> validateFor() returns false: T1 no longer exists server-side
This is the correct, intended outcome from a security standpoint — a consumed token must never validate again. It is a UX problem to solve at the application layer, not a defect to patch in the library.
Refresh pattern
Expose a same-origin, no-store endpoint that issues a fresh pair on demand, and re-fetch it
whenever a page may be showing stale form state:
// GET /csrf/refresh?container=checkout $pair = $csrf->issueFor($_GET['container'] ?? 'default'); header('Content-Type: application/json'); header('Cache-Control: no-store, private'); echo json_encode(['csrf' => $pair->token, 'csrf_proof' => $pair->proof]);
window.addEventListener('pageshow', (event) => { if (!event.persisted) return; // only act on a BFCache restore document.querySelectorAll('[data-csrf-container]').forEach(async (form) => { const container = form.dataset.csrfContainer; const res = await fetch(`/csrf/refresh?container=${encodeURIComponent(container)}`, { credentials: 'same-origin', cache: 'no-store', }); const { csrf, csrf_proof } = await res.json(); form.querySelector('[name="_csrf"]').value = csrf; const proofField = form.querySelector('[name="_csrf_proof"]'); if (proofField && csrf_proof) proofField.value = csrf_proof; }); });
Do not put the token or proof in a URL query string (history, logs, Referer leakage). The
refresh endpoint must only issue tokens on GET — never perform a state change there.
Monitoring expiration
getExpiresInFor() reports remaining TTL without consuming the token, so a long-lived form
(e.g. a multi-step wizard left idle in a tab) can warn the user or refresh proactively before
submission fails:
$secondsLeft = $csrf->getExpiresInFor('checkout'); // null = no token yet, 0 = expired, PHP_INT_MAX = TTL disabled
// Client-side countdown fed by a value the server embedded when rendering the page. let secondsLeft = 900; setInterval(() => { secondsLeft--; if (secondsLeft === 60) { // e.g. show a "your session is about to expire" banner, or call the refresh endpoint proactively } }, 1000);
Applications that intentionally reuse a token across multiple requests are outside this
library's model — single_use is not configurable, by design.
Migration
Upgrading to this release (Origin Binding, Session-Bound Proof)
- PHP requirement raised to
>=8.2(CsrfPairis areadonlyclass — the class-levelreadonlymodifier is PHP 8.2+; readonly properties alone would only need 8.1). This is a breaking change for consumers on PHP 7.4–8.0 — bump your owncomposer.jsonaccordingly. Csrf::__construct()gained an optional 4th parameter,?SessionBindingProviderInterface. Existing call sites are unaffected.withContainer()gained two optional keys:origin(see Origin Binding) andrequire_proof(see Session-Bound Proof). Both default to disabled — existing container configs behave identically until you opt in.validateFor()/validateCachedFor()gained trailing optional parameters ($proof, and$rawToken/$proofrespectively). Existing positional call sites are unaffected;CsrfCacheWrapper::validateFor()gained an optional trailing$prooftoo.- New:
Csrf::issueFor()andCsrfCacheWrapper::issueFor()return aCsrfPair(token + proof).generateFor()is unchanged and never computes a proof. HtmlHelper::input()(and therefore the Plates/Twig/Blade helpers, which all delegate to it) now callsissueFor()internally and transparently emits a_csrf_proofhidden field whenever a session is active. No template changes are required; the extra field has no effect on validation unless the receiving container setsrequire_proof.- Fixed:
HtmlHelper::token()was referenced by the Plates helper (csrf_token()) but did not exist, making that function fatal at call time. It is now implemented.
From 1.2.1 → 1.3.x
- What changed: The library is now container‑aware. Internal session storage moved under
$_SESSION['_csrf_v2'][<prefix><container>]and tokens are bound to container id via AAD and payload. - Backwards compatibility: Existing calls to
generate()/validate()continue to work for the implicit"default"container. - Recommended: Start calling
generateFor('<form-id>')/validateFor('<form-id>', $token)for each separate form.
From 1.0.0 → 1.1.x
- Namespace unified under
rafalmasiarek\Csrfand PSR‑4 autoloading.
Examples
See example/ for drop‑in snippets:
basic/index.phpcontainers/index.phpfileCache/index.phpipOverride/index.phpmysql/schema.sql
View Helpers
All three helpers below render through HtmlHelper::input(), which transparently emits a
_csrf_proof hidden field alongside _csrf whenever a session is active — no changes needed
to benefit from it (see Session-Bound Proof).
Plates (League\Plates)
use League\Plates\Engine; use rafalmasiarek\Csrf\Csrf; use rafalmasiarek\Csrf\Helpers\Plates\CsrfExtension; $view = new Engine(__DIR__.'/views'); $csrf = new Csrf($masterKey, 900); CsrfExtension::register($view, $csrf); // In template: <?= csrf_field('signup') ?>
Twig
use Twig\Environment; use Twig\Loader\FilesystemLoader; use rafalmasiarek\Csrf\Csrf; use rafalmasiarek\Csrf\Helpers\Twig\CsrfExtension; $twig = new Environment(new FilesystemLoader(__DIR__.'/views')); $csrf = new Csrf($masterKey, 900); $twig->addExtension(new CsrfExtension($csrf)); // In template: {{ csrf_field('signup')|raw }}
Blade (Laravel)
use Illuminate\View\Compilers\BladeCompiler; use rafalmasiarek\Csrf\Csrf; use rafalmasiarek\Csrf\Helpers\Blade\CsrfBlade; // In a service provider boot(): CsrfBlade::register($this->app->make(BladeCompiler::class), app(Csrf::class)); // In Blade: @csrfField('signup')
License
MIT