---
title: 'Request Forgery Protection'
source: 'https://academia.sh/en/courses/php-web/request-forgery-protection'
course: 'PHP on the Web: State, Data and Security'
language: en
updated: '2026-10-21T05:03:37+00:00'
license: 'CC BY-SA 4.0'
objectives:
    - 'Measures who changed the result in an email update without a token and with a token'
    - 'Generates and compares a request forgery token with random_bytes(), bin2hex() and hash_equals()'
    - 'Explains that the SameSite cookie attribute is browser behavior and is placed next to the token, not in its place'
---

# Request Forgery Protection

Telling apart whether a request was made by the user's own application or by another site with a request forgery token, the measured effect of the token, and the limit of the SameSite cookie attribute.

Escaping bounded the text printed onto the page. A request comes from another direction: while a user is signed in to the application, a page on another site can send a request to the application from the user's browser. The question for preventing **cross-site request forgery (CSRF)** is this: who determines whether this request came from the user's own screen or from somewhere else? This lesson measures whether the request is accepted; it does not ask whether the code of a rejected request ran.

**SE4.** In this lesson the "other site" is only an array record: `'origin' => 'https://other.test'`. No real second site, cookie or browser is set up. The requests are arrays; what is measured is whether the email update is accepted. The token is printed nowhere, only its length is printed.

## Tokenless and Tokened Update

A **request forgery token (CSRF token)** is a random value that the server puts in the form it shows the user and expects back with the request. A request that comes from the user's own screen carries this value; a page on another site cannot carry it, because it does not know the value. The token is generated with `random_bytes()`, made readable with `bin2hex()`, and compared with `hash_equals()`: this function compares two strings in a time that does not depend on the position where the first difference is found.

```php
<?php
// request.php — unprotected and tokened email update
declare(strict_types=1);

function generateToken(): string {
    return bin2hex(random_bytes(16));
}

function update(string $current, array $request, ?string $expected): string {
    if ($expected !== null && !hash_equals($expected, (string) ($request['token'] ?? ''))) {
        return $current;
    }
    return $request['email'];
}
```

`update()` returns the new email; if the request is rejected, it returns the current value unchanged. If `$expected` is null, there is no protection. Three requests are tried with two methods: from the user's own screen, from another site without a token, and from another site with a wrong token.

```php
<?php
// update.php — three requests, two methods
declare(strict_types=1);
require 'request.php';

$current = 'alice@example.test';
$token = generateToken();
$requests = [
    'from the app   ' => ['origin' => 'https://app.test', 'email' => 'new@example.test', 'token' => $token],
    'without token  ' => ['origin' => 'https://other.test', 'email' => 'other@example.test'],
    'wrong token    ' => ['origin' => 'https://other.test', 'email' => 'other@example.test', 'token' => str_repeat('0', 32)],
];
foreach ($requests as $name => $request) {
    $unprotected = update($current, $request, null) !== $current;
    $tokened = update($current, $request, $token) !== $current;
    echo $name, ' · unprotected ', $unprotected ? 'accepted' : 'rejected', ' · tokened ', $tokened ? 'accepted' : 'rejected', "\n";
}
echo 'token length ', strlen($token), "\n";
echo 'two tokens ', generateToken() === generateToken() ? 'same' : 'different', "\n";
```

```
from the app    · unprotected accepted · tokened accepted
without token   · unprotected accepted · tokened rejected
wrong token     · unprotected accepted · tokened rejected
token length 32
two tokens different
```

The unprotected method accepted all three requests; there was no difference between the other site's request and the user's own request. The tokened method accepted only the request that carried the token; the request without a token and the request with a wrong token were rejected. The token is 32 characters (16 bytes, written in hexadecimal) and two generations gave different values. What is measured is only acceptance or rejection; the token itself is not printed, because a printed token would be another value on the next run, and a secret value has no place in output.

## Party: Who Makes the Update

The same measurement connects to the `party.php` core. The decision order is client, third party, environment; if none could change it, the server.

```php
<?php
// party.php — who can change this result?
declare(strict_types=1);

// The parties outside the code, in decision order: every request, every external call, every deployment.
const EXTERNAL_PARTIES = ['client', 'third party', 'environment'];

/** Computes the result once without deviation, then once with each external party's deviation. */
function probe(callable $result, array $deviations): array {
    $baseline = $result([]);
    $changed = [];
    foreach (EXTERNAL_PARTIES as $party) {
        if (array_key_exists($party, $deviations)) {
            $changed[$party] = $result([$party => $deviations[$party]]) !== $baseline;
        }
    }
    return $changed;
}

/** The first external party that can change the result; the server if none can. */
function party(array $changed): string {
    foreach (EXTERNAL_PARTIES as $p) {
        if ($changed[$p] ?? false) return $p;
    }
    return 'server';
}

function partyReport(string $name, callable $result, array $deviations): string {
    $changed = probe($result, $deviations);
    $lines = [];
    foreach ($changed as $p => $c) $lines[] = $p . ($c ? ' changed' : ' could not change');
    $owner = party($changed);
    echo "$name → $owner\n    ", implode(' · ', $lines), "\n";
    return $owner;
}
```

The result is the email after the update. The deviation is a request sent by the third party without a token: the other site's record and its own email. The undeviated request is the user's own tokened request.

```php
<?php
// forgery.php — who changed the email
declare(strict_types=1);
require 'party.php';
require 'request.php';

$token = generateToken();
$own = ['origin' => 'https://app.test', 'email' => 'alice@example.test', 'token' => $token];
$other = ['origin' => 'https://other.test', 'email' => 'other@example.test'];

partyReport('without token', fn(array $d) => update('alice@example.test', $d['third party'] ?? $own, null), ['third party' => $other]);
partyReport('with token', fn(array $d) => update('alice@example.test', $d['third party'] ?? $own, $token), ['third party' => $other]);
```

```
without token → third party
    third party changed
with token → server
    third party could not change
```

In the update without a token, the **third party** changed the email: the other site's request replaced the user's email with its own value. In the update with a token, the third party could not change it; the remaining owner is the **server**, because the code that generates and compares the token is there. The token value stays in the server's hands and only a copy of it is put into the form; the party that sends the request cannot produce its counterpart as long as it does not see the copy.

## The Bounding Measurement

Browsers have a **cookie attribute** that limits sending the cookie with a request from another site: `SameSite`. The server asks for this attribute by writing it into the cookie header; enforcing it belongs to the browser. The only thing that can be measured on the server side is whether the attribute is inside the header string.

```php
<?php
// samesite.php — SameSite is only a header string on the server
declare(strict_types=1);
require 'party.php';
require 'request.php';

function cookieHeader(string $name, string $value, string $sameSite): string {
    return 'Set-Cookie: ' . $name . '=' . $value . '; SameSite=' . $sameSite . '; HttpOnly';
}

echo cookieHeader('session', 'value', 'Lax'), "\n";

$own = ['origin' => 'https://app.test', 'email' => 'alice@example.test'];
$other = ['origin' => 'https://other.test', 'email' => 'other@example.test'];
partyReport('SameSite only', fn(array $d) => update('alice@example.test', $d['third party'] ?? $own, null), ['third party' => $other]);
```

```
Set-Cookie: session=value; SameSite=Lax; HttpOnly
SameSite only → third party
    third party changed
```

`SameSite=Lax` is in the header string, but the result in the server code did not change: the third party again changed the email. This is the expected result. The effect of the attribute cannot be measured in any line that runs on the server, because it is the browser that chooses to add or not to add the cookie to the request; the server only asks. So `SameSite` is not put in place of the token, it is put next to it: the attribute adds a layer on the browser side, and the token changes the owner on the server side. Without the second, the server's result stays in the third party's hands.

## Summary

- The measured class of this lesson is the **third party** in the update without a token and the **server** in the update with a token: without a token the other site's request changed the email; with a token it could not.
- The request forgery token is generated with `random_bytes()` and `bin2hex()` (32 characters) and compared with `hash_equals()`; the token itself is not printed, its length is.
- The defense takes the result away from its owner: a wrong and a missing token were rejected in the same way, and the email stayed at its current value.
- The `SameSite` cookie attribute is browser behavior; the server only asks for it and it appears in the header string. It did not change the result on the server, and it is put next to the token, not in its place.

## Next Step

If the request now comes from the user's screen, the same question is asked when the credential that comes with the request is stored: how is the password stored, and who can change the stored hash? The Password Storage lesson measures this.
