Synchronizer token, double submit cookie and SameSite combined
Anyone running custom PHP applications without Symfony or Laravel has to build CSRF protection themselves. With random_bytes for token generation, hash_equals for constant time comparison and a clean session binding, robust CSRF protection can be built in a few lines of PHP, without any third party dependency.
Table of Contents
- 1. What CSRF is and why custom protection is needed
- 2. The synchronizer token pattern in detail
- 3. Secure token generation with random_bytes
- 4. A CSRF token manager in practice
- 5. Double submit cookie pattern for stateless APIs
- 6. SameSite cookies as an additional defense layer
- 7. Combining CSRF protection for forms and AJAX/Fetch
- 8. Common mistakes in custom CSRF implementations
- 9. CSRF protection patterns compared
- 10. Summary
- 11. FAQ
1. What CSRF is and why custom protection is needed
CSRF (Cross-Site Request Forgery) is an attack where a malicious website tricks the browser of a logged in user into sending an unnoticed request to another application. The browser automatically sends the valid session cookies along, so the target application treats the request as legitimate. A classic example: a user is logged into an admin area, visits a crafted page in a second tab, and that page triggers a password change or a money transfer through a hidden form, without the user noticing anything. CSRF protection must prevent exactly this kind of abuse.
Anyone working with a framework like Symfony usually gets CSRF protection included automatically, for example through the form component. But as soon as a lean custom PHP project without a framework appears, say an internal tool, a legacy system or a microservice interface, that protection is completely missing, and developers have to build it themselves. This is exactly where most mistakes happen: tokens generated too short, produced with an insecure random source, or checked with a plain string comparison that is vulnerable to timing attacks. This article shows how solid CSRF protection can be built without any framework dependency at all, step by step and with complete code.
2. The synchronizer token pattern in detail
The synchronizer token pattern is the most widely used method for CSRF protection and works on a simple principle: on every page load containing a form, the server generates a random token, stores it in the user's session and embeds it as a hidden field in the form. When the browser submits the form, the token sent along must exactly match the value stored in the session. A foreign site does not know this token, because it has no access to the victim's session, and therefore cannot forge a valid request.
The decisive point for reliable CSRF protection is binding the token to the server side session, not to a cookie alone. An attacker can make cookies get sent automatically, but cannot read or guess a server side session value, as long as it was generated with enough entropy. It is also important that the token stays consistent per session, not necessarily per form, so multiple simultaneously open tabs are not invalidated by constant token rotation. Rotation after a successful login is still sensible, to additionally complicate session fixation attacks.
3. Secure token generation with random_bytes
The security of the entire CSRF protection hinges on the quality of the random numbers used. Since version 7, PHP offers random_bytes() as a cryptographically secure random source that accesses operating system level entropy (/dev/urandom on Linux, CryptGenRandom on Windows). The outdated function mt_rand() is off limits here, because its Mersenne Twister algorithm becomes predictable once an attacker has observed enough outputs. For a CSRF token, 32 bytes of raw entropy, or 64 characters in hexadecimal representation, are completely sufficient to rule out practical brute force attacks.
For comparing two tokens, the plain operator == or === must never be used, because string comparisons in PHP internally stop character by character as soon as a difference is found. This early termination can be measured through response time and theoretically enables a timing attack that guesses the token character by character. The function hash_equals() instead always compares in constant time, regardless of how many characters match, and is therefore the only correct choice for security sensitive string comparisons like CSRF protection.
<?php
declare(strict_types=1);
/**
* Generates a cryptographically secure CSRF token.
* Uses random_bytes() instead of mt_rand() or uniqid().
*/
function generate_csrf_token(): string
{
// 32 bytes of raw entropy, encoded as a 64-character hex string
return bin2hex(random_bytes(32));
}
/**
* Compares two tokens in constant time to prevent timing attacks.
* Never use == or === for security-sensitive comparisons.
*/
function csrf_tokens_match(string $submitted, string $stored): bool
{
if ($submitted === '' || $stored === '') {
return false;
}
return hash_equals($stored, $submitted);
}
4. A CSRF token manager in practice
Instead of scattering individual functions across the codebase, it is best to bundle CSRF protection into a small, reusable class. This class encapsulates session interaction, token generation and validation in one place, so every form page uses the same, tested logic. It is important that the session is already started before the class is instantiated, and that a secure session cookie with httponly and secure has been set. The token manager creates a new token if needed, but returns the same token on repeated calls as long as the session stays valid.
Validation is best performed centrally in a front controller or middleware like entry point, rather than repeated in every single form handler. This prevents a single forgotten call from opening a security hole. For state changing requests, meaning POST, PUT, PATCH and DELETE, the check should be mandatory, but not for plain GET requests, because GET should not trigger side effects per HTTP specification, and CSRF protection there would be meaningless anyway.
<?php
declare(strict_types=1);
/**
* Encapsulates CSRF token generation, storage and validation.
* Requires an already started PHP session.
*/
final class CsrfTokenManager
{
private const SESSION_KEY = '_csrf_token';
public function __construct()
{
if (session_status() !== PHP_SESSION_ACTIVE) {
throw new RuntimeException('Session must be started before using CsrfTokenManager');
}
}
/**
* Returns the current token, generating one if none exists yet.
*/
public function getToken(): string
{
if (empty($_SESSION[self::SESSION_KEY])) {
$_SESSION[self::SESSION_KEY] = bin2hex(random_bytes(32));
}
return $_SESSION[self::SESSION_KEY];
}
/**
* Validates a submitted token against the session value.
*/
public function validate(?string $submittedToken): bool
{
$stored = $_SESSION[self::SESSION_KEY] ?? '';
if ($stored === '' || $submittedToken === null || $submittedToken === '') {
return false;
}
return hash_equals($stored, $submittedToken);
}
/**
* Rotates the token, useful after login or privilege changes.
*/
public function rotate(): string
{
$_SESSION[self::SESSION_KEY] = bin2hex(random_bytes(32));
return $_SESSION[self::SESSION_KEY];
}
}
// Usage in a form handler
session_start();
$csrf = new CsrfTokenManager();
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (!$csrf->validate($_POST['csrf_token'] ?? null)) {
http_response_code(403);
exit('Invalid CSRF token');
}
// Process the trusted request here
}
5. Double submit cookie pattern for stateless APIs
Not every application has server side sessions, especially stateless REST APIs with JWT authentication often lack the classic session that a synchronizer token could be bound to. For this case, the double submit cookie pattern offers an alternative approach to CSRF protection. The server sets a random token as a cookie, which JavaScript in the frontend reads and additionally sends as a header on every request. The server then compares the cookie value and the header value, and the request is only considered legitimate on a match.
The security of this pattern relies on the browser's same origin policy: a foreign site can make the cookie get sent automatically, but it cannot read the cookie value through JavaScript and therefore cannot duplicate it as a header, provided the cookie is not marked httponly. That is exactly the special feature of this CSRF protection: the token cookie must, as an exception here, not be httponly, because JavaScript needs to access it. This exception is often overlooked and leads to misconfigured implementations that render the protection useless.
<?php
declare(strict_types=1);
/**
* Double-submit cookie CSRF protection for stateless APIs.
* The cookie is intentionally NOT httponly so JavaScript can read it.
*/
final class DoubleSubmitCsrf
{
private const COOKIE_NAME = 'csrf_token';
private const HEADER_NAME = 'X-CSRF-Token';
public function issueCookie(): void
{
$token = bin2hex(random_bytes(32));
setcookie(self::COOKIE_NAME, $token, [
'expires' => 0,
'path' => '/',
'secure' => true,
'httponly' => false, // must be readable by JavaScript
'samesite' => 'Strict',
]);
}
public function validateRequest(): bool
{
$cookieToken = $_COOKIE[self::COOKIE_NAME] ?? '';
$headerToken = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
if ($cookieToken === '' || $headerToken === '') {
return false;
}
return hash_equals($cookieToken, $headerToken);
}
}
6. SameSite cookies as an additional defense layer
The SameSite cookie attribute is not a complete solution, but it significantly strengthens any CSRF protection when set correctly. With SameSite=Strict, the browser sends the session cookie exclusively on requests originating from the own domain, even if a user clicks a link from a foreign site. With SameSite=Lax, the default value in modern browsers, the cookie is sent on simple top level navigations (normal link clicks), but not on form submits via POST from foreign sites or on images and iframes.
Why is SameSite alone still not enough as complete CSRF protection? First, some older browsers do not support the attribute or ignore it in certain scenarios. Second, SameSite=Lax explicitly does not protect against all attack vectors, for example not against subdomain attacks when several applications share the same parent domain. Third, SameSite is a purely browser side feature that an application should not rely on if it has to serve users with outdated or exotic clients. Combining a synchronizer token with SameSite=Strict is therefore significantly more robust than any single measure.
<?php
declare(strict_types=1);
/**
* Starts a session with hardened cookie parameters,
* including SameSite as an additional CSRF defense layer.
*/
function start_hardened_session(): void
{
session_set_cookie_params([
'lifetime' => 0,
'path' => '/',
'domain' => '',
'secure' => true, // only sent over HTTPS
'httponly' => true, // not readable by JavaScript
'samesite' => 'Strict', // not sent on cross-site requests
]);
session_start();
// Regenerate the session ID after login to prevent session fixation
if (!isset($_SESSION['initialized'])) {
session_regenerate_id(true);
$_SESSION['initialized'] = true;
}
}
7. Combining CSRF protection for forms and AJAX/Fetch
Modern PHP applications rarely consist only of classic HTML forms, but combine server rendered pages with AJAX calls through Fetch or XMLHttpRequest. CSRF protection must cover both paths without duplicating code. A proven pattern: the token is rendered once server side into a meta tag in the HTML head, and a small JavaScript snippet reads this value and automatically attaches it to every fetch request as a header before the request is sent.
On the PHP side, a central entry point checks both the POST field and the header, depending on which channel was used for the given request. It is important to name the header consistently, for example X-CSRF-Token, and to expect it in the server side middleware the same way as the form field csrf_token. This double check prevents gaps that arise when a developer only considers one of the two paths during the migration from classic forms to an AJAX heavy interface.
8. Common mistakes in custom CSRF implementations
The most common mistake in self built CSRF protection is using uniqid() or mt_rand() for token generation. Both functions are unsuitable for cryptographic purposes: uniqid() is based on the current system time in microseconds and is therefore predictable within narrow bounds, mt_rand() uses a pseudo random generator that is not cryptographically secure. Only random_bytes() or the derived random_int() provide the entropy needed for resilient CSRF protection.
A second common mistake is transmitting the token as a GET parameter in the URL. URLs end up in server logs, browser history, referrer headers and proxy caches, which can unintentionally expose the token to third parties. A third mistake concerns the check itself: if hash_equals() is forgotten and === is used instead, it theoretically opens the door to timing attacks, even though these are hard to exploit in practice due to network jitter. A fourth mistake is checking CSRF protection only in the frontend via JavaScript, without server side validation, which renders any protection completely useless, since an attacker simply bypasses the frontend.
9. CSRF protection patterns compared
Depending on the application's architecture, session based or stateless, different approaches to CSRF protection are more or less suitable. The following table compares the three patterns discussed with their respective strengths.
| Pattern | Requirement | Strength | Weakness |
|---|---|---|---|
| Synchronizer token | Server side session | Highest security, well established | Requires session storage |
| Double submit cookie | JavaScript access to cookie | Works without a session | Cookie must not be httponly |
| SameSite alone | Modern browser | Easy to enable | Not a complete protection |
| Token + SameSite=Strict | Session and modern browser | Layered defense | Slightly more implementation effort |
In practice, for most server rendered PHP applications, the synchronizer token pattern combined with SameSite=Strict is recommended. For pure APIs without a session, the double submit cookie pattern is the more pragmatic choice, as long as the httponly exception is consciously documented. Both approaches can be encapsulated in small, testable PHP classes, without needing a complete framework.
Mironsoft
PHP security consulting and audits for custom software
Custom PHP code without reliable CSRF protection?
We audit existing PHP applications for CSRF gaps and implement robust, tested protection mechanisms, whether based on sessions, JWT or a hybrid architecture.
Security audit
Systematic review of forms and API endpoints for CSRF gaps
Implementation
Custom token managers, double submit cookies and SameSite configuration
Legacy migration
Retrofitting CSRF protection into grown PHP projects without a framework
10. Summary
Solid CSRF protection without a framework can be built with a few clearly separated PHP building blocks: random_bytes() for token generation, hash_equals() for constant time comparison, a session bound storage via the synchronizer token pattern, and, where no session exists, the double submit cookie pattern as an alternative. SameSite=Strict complements both approaches as an additional defense layer, but does not fully replace them, since browser support and subdomain scenarios can still leave gaps.
Anyone who encapsulates these building blocks in a small, well tested class instead of scattering them across the codebase significantly reduces the risk of forgotten checks. Central validation in a front controller, consistent use of hash_equals() and avoiding tokens in URLs are the three points that make the biggest difference between real and only apparent CSRF protection.
CSRF Protection Without a Framework — The Essentials at a Glance
Token generation
random_bytes(32) instead of mt_rand() or uniqid() for cryptographically secure tokens.
Comparison
hash_equals() instead of ===, to rule out timing attacks during token comparison.
Without a session
Double submit cookie pattern for stateless APIs, cookie deliberately not httponly.
Additional layer
SameSite=Strict complements, but does not replace, the token pattern.