CSRF Protection

Yo Yogesh.M Updated 17 Oct 2026
6 min read ·Lesson 71 of 109
In this post

What Is CSRF? In simple terms: Cross-Site Request Forgery (CSRF) tricks a logged-in user's browser into sending a request to your site without the user meaning to . Imagine Asha is logged into her sho

What Is CSRF?

In simple terms: Cross-Site Request Forgery (CSRF) tricks a logged-in user's browser into sending a request to your site without the user meaning to. Imagine Asha is logged into her shop account. She visits another website, which contains a hidden form that submits "change my email to attacker@example.com" to your shop. Her browser automatically sends her session cookie along — and your site, seeing a valid session, makes the change.

Technically: Browsers attach cookies to requests based on the destination, not on which site created the request. CSRF exploits this: an attacker's page causes the victim's browser to send a state-changing request (form POST, image request, fetch()) to the target application with the victim's credentials. The application must therefore verify that a request was intentionally sent from its own pages — typically with a secret CSRF token that other sites cannot read.

What an Attack Looks Like

<!-- On evil.example — the victim only has to open this page -->
<form action="https://shop.example/account/email" method="post" id="f">
    <input type="hidden" name="email" value="attacker@example.com">
</form>
<script>document.getElementById('f').submit();</script>

The attacker cannot read the response (the same-origin policy prevents that), but the damage is already done: the email is changed, and a password reset can follow. Any action that changes state is a potential target — changing settings, placing orders, transferring money, deleting data.


The Defenses

DefenseHow It WorksRole
Synchronizer tokenA random secret stored in the session and included in every form; the server compares themPrimary defense
SameSite cookiesThe browser does not send the session cookie with cross-site POST requests (Lax/Strict)Strong second layer
Origin/Referer checkThe server verifies that the request's Origin header matches its own siteAdditional check
Custom request headerAJAX requests send a header (such as X-CSRF-Token) that cross-site forms cannot addFor JavaScript APIs
Re-authenticationAsk for the password again for critical actionsFor the most sensitive operations

Also essential: GET requests must never change state. Links, images, and prefetching trigger GET requests freely.


Implementing Synchronizer Tokens

<?php
final class CsrfGuard
{
    private array $session;

    public function __construct(array &$session)
    {
        $this->session = &$session;
    }

    public function token(): string
    {
        return $this->session['_csrf'] ??= bin2hex(random_bytes(32));
    }

    public function field(): string
    {
        return '<input type="hidden" name="_csrf" value="' . htmlspecialchars($this->token()) . '">';
    }

    public function isValid(mixed $submitted): bool
    {
        $expected = $this->session['_csrf'] ?? '';

        return is_string($submitted)
            && $expected !== ''
            && hash_equals($expected, $submitted);
    }

    public function rotate(): void
    {
        $this->session['_csrf'] = bin2hex(random_bytes(32));
    }
}

$session = [];   // in a real application: $_SESSION after session_start()
$csrf = new CsrfGuard($session);

$token = $csrf->token();
echo "Token length: " . strlen($token) . "\n";

var_dump($csrf->isValid($token));
var_dump($csrf->isValid('guessed-token'));
var_dump($csrf->isValid(null));

$csrf->rotate();
var_dump($csrf->isValid($token));

Output

Token length: 64
bool(true)
bool(false)
bool(false)
bool(false)

How It Works

  1. token() creates a 64-character hex token from 32 cryptographically secure random bytes (random_bytes()) the first time it is needed and stores it in the session. Attackers cannot guess it.
  2. field() produces a hidden form field with the escaped token.
  3. isValid() checks that a token was submitted, that the session has one, and that they match. hash_equals() compares in constant time, so response timing reveals nothing about the token.
  4. Missing, empty, or wrong tokens are rejected — an empty session token never matches an empty submission.
  5. rotate() replaces the token; old tokens become invalid. Rotate after login and logout.

Using It in Forms and Handlers

<?php
session_start();
$csrf = new CsrfGuard($_SESSION);

if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST') {
    if (!$csrf->isValid($_POST['_csrf'] ?? null)) {
        http_response_code(419);
        exit('Your session has expired. Please reload the page and try again.');
    }

    // ... process the change ...
}
?>
<form method="post" action="/account/email">
    <?= $csrf->field() ?>
    <label>New email <input type="email" name="email" required></label>
    <button type="submit">Change email</button>
</form>

The attacker's page cannot read your page (same-origin policy), so it cannot learn the token and cannot build a valid request.

Protecting Every State-Changing Request

Rather than remembering the check in each script, run it centrally — in a front controller or framework middleware — for all POST, PUT, PATCH, and DELETE requests:

if (!in_array($_SERVER['REQUEST_METHOD'] ?? 'GET', ['GET', 'HEAD', 'OPTIONS'], true)) {
    $submitted = $_POST['_csrf'] ?? $_SERVER['HTTP_X_CSRF_TOKEN'] ?? null;
    if (!$csrf->isValid($submitted)) {
        http_response_code(419);
        exit('Invalid or expired form. Please reload the page.');
    }
}

For AJAX, place the token in the page (<meta name="csrf-token" content="...">) and send it in the X-CSRF-Token header (see AJAX PHP).


SameSite Cookies as a Second Layer

session_set_cookie_params(['samesite' => 'Lax', 'secure' => true, 'httponly' => true]);

With SameSite=Lax (the default in modern browsers when no attribute is set), the session cookie is not sent with cross-site POST requests, so the attack form above arrives without a session. Tokens remain necessary because:

  • older browsers and some embedded browsers do not support SameSite,
  • requests from sibling subdomains count as "same-site" (evil.shop.example vs www.shop.example),
  • top-level GET navigations still carry Lax cookies, which matters if any GET request changes state.

Checking the Origin Header

Modern browsers send an Origin header with POST requests. Verifying it is a cheap extra check:

<?php
function isSameOrigin(?string $origin, string $expected): bool
{
    return $origin === null || $origin === $expected;
}

$expected = 'https://shop.example';

var_dump(isSameOrigin('https://shop.example', $expected));
var_dump(isSameOrigin('https://evil.example', $expected));
var_dump(isSameOrigin(null, $expected));

// In a handler: isSameOrigin($_SERVER['HTTP_ORIGIN'] ?? null, 'https://shop.example')

Output

bool(true)
bool(false)
bool(true)

Requests with a foreign Origin are rejected outright. A missing header is allowed here (some privacy tools strip it), so the token check remains the deciding defense.


Special Cases

CaseAdvice
Login formsProtect them too: "login CSRF" logs the victim into the attacker's account, so data the victim enters ends up with the attacker
LogoutUse POST with a token, so other sites cannot log users out
APIs with token authentication (Authorization: Bearer ...)Not vulnerable to CSRF, because browsers do not add such headers automatically — but cookie-based APIs are
Multiple tabsA per-session token works across tabs; per-form one-time tokens cause errors when several tabs are open
FrameworksLaravel, Symfony, and others include CSRF protection; keep it enabled

Common Mistakes

Changing State With GET Requests

Effect: <img src="https://shop.example/cart/clear"> performs actions.

Fix: Use POST (or PUT/PATCH/DELETE) with tokens for every change.

Comparing Tokens With == or Without Checking Emptiness

Effect: Timing leaks or bypasses with empty values.

Fix: Require a non-empty session token and use hash_equals().

Generating Tokens With rand(), uniqid(), or md5(time())

Effect: Predictable tokens.

Fix: Use random_bytes().

Forgetting AJAX Endpoints

Effect: JSON endpoints that change data are left unprotected.

Fix: Require the token header for all state-changing requests.

Relying on SameSite Alone

Effect: Gaps with subdomains and older browsers.

Fix: Combine SameSite with tokens.


Best Practices

  • Never change state with GET requests.
  • Protect every state-changing request with a session-bound token generated by random_bytes(), verified with hash_equals(), in one central place.
  • Set session cookies with SameSite=Lax (or Strict), Secure, and HttpOnly.
  • Check the Origin header as an additional layer, and rotate tokens on login and logout.
  • Use your framework's built-in CSRF protection where available.

Official reference: https://www.php.net/manual/en/function.random-bytes.php

0 Comments

Reviewed before they appear

No comments yet.

PHP
PHP Home PHP Switch PHP Match Alternative Syntax PHP Loops While Loop Do While Loop For Loop Foreach Loop Nested Loops Break Statement PHP Home PHP Intro PHP Install PHP Built-in Web Server PHP Syntax PHP Tags PHP and HTML PHP Comments PHP Multiline Comments PHP Variables Variables Scope PHP Casting Logical Operators Null Coalescing Operator Spaceship Operator Operator Precedence Continue Statement Modify Strings PHP If...Else...Elseif PHP If Modify Strings PHP If Operators PHP Functions Concatenate Strings PHP If...Else Slicing Strings User-defined Functions Slicing Strings PHP Shorthand if Escape Characters Default Argument Values PHP Nested if Pass by Reference String Interpolation Named Arguments PHP File Upload Variadic Functions Return Values Search in Strings Multibyte Strings Type Declarations in Functions PHP Numbers PHP Math PHP Floats PHP Random Numbers PHP Constants PHP Properties PHP Methods PHP Constructor Constructor Property Promotion PHP Cookies PHP Destructor PHP Access Modifiers Readonly Properties PHP Sessions PHP Inheritance Method Overriding PHP Filters The final Keyword CSRF Protection PHP Filters Advanced Session Security Secure Cookies PHP Magic Constants File Upload Security PHP Callback Functions PHP Operators Filesystem Security PHP JSON Arithmetic Operators Error Reporting in Production Hiding PHP Assignment Operators PHP Configuration (php.ini) Comparison Operators PHP Command Line (CLI) PHP Exceptions Increment / Decrement Operators Migrating to PHP 8 PHP Form Validation PHP Form Required PHP Form URL/E-mail Error Handling Form Input Sanitizing Escaping Output (htmlspecialchars) Custom Error Handler Checkbox, Radio and Select Inputs Sticky Forms PHP Form Complete PHP Date and Time Date Formatting Error Logging HTTP Headers and Redirects Output Buffering Sending Email PHP cURL Consuming REST APIs PHP Generators
Ask about this post
AI Ask about this post

Ask questions about CSRF Protection and get answers drawn from it.

Signed-in readers only.