Implementing CSRF Protection Without a Framework
AI generated
<?php
8.4
PHP · Security · Sessions · Forms
Implementing CSRF Protection Without a Framework
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.

18 min read random_bytes · hash_equals · session · SameSite PHP 8.1+

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.

11. FAQ: CSRF Protection Without a Framework

1What is CSRF and why does it affect small projects?
CSRF exploits automatically sent session cookies. Any session based application is affected, no matter how small.
2Why random_bytes instead of mt_rand?
random_bytes is cryptographically secure, mt_rand is predictable after enough observed outputs.
3Is SameSite=Strict enough alone?
No, it is a valuable additional layer but does not replace the token pattern due to browser and subdomain gaps.
4Why must the cookie not be httponly?
For the double submit pattern, JavaScript must be able to read the cookie value to duplicate it as a header.
5Why no token in the URL?
URLs end up in logs, history and referrer headers. Tokens belong in the POST body or a header.
6Does every form need its own token?
Not necessarily, one token per session usually suffices. Rotation after login increases security.
7Does GET need to be checked too?
No, GET should have no side effects per specification. Checks belong on POST, PUT, PATCH, DELETE.
8How does it work with AJAX interfaces?
Render the token via meta tag, read it with JavaScript, send it as a header on every fetch, check both centrally server side.
9What happens without hash_equals?
Theoretical timing attack risk from early string comparison termination, hard in practice but avoidable.
10Does the pattern fit single page apps?
Yes with a shared session with the backend, otherwise the double submit cookie pattern is the better fit.