# Verify the anti-replay binding in PHP

> Core PHP functions that keep nonces in the session and check that a WhiskerJS result is fresh and unchanged, plus a router for PHP's built-in server.

Source: https://whiskerjs.com/guides/verify-php/ (last updated 2026-10-27)

> **In plain words:**
>
> Before the form is submitted, your PHP code puts a random one-time code and the time in the visitor’s session and hands both to the page. WhiskerJS seals the code into its result. When the form arrives, PHP checks that the code is in this session, unused and recent, and that the seal still matches every field of the result.

## Install

Nothing to install on the server: the functions use only PHP core (`random_bytes`, `hash`, `hash_equals`, `json_decode`, sessions); written for PHP 8 and tested with PHP 8.3. The page side needs WhiskerJS built from source; it is not on npm yet, the npm package is coming. See the [plain JavaScript guide](https://whiskerjs.com/guides/javascript/#install-build-from-source).

## The functions

`whisker_binding.php`:

```php
<?php
// Check the anti-replay binding of a WhiskerJS result (WhiskerJS 0.1.0,
// signalsVersion 11, idVersion 2). No extensions beyond the PHP core.
declare(strict_types=1);

const WHISKER_VERSIONS = ['0.1.0', '11', '2']; // version, signalsVersion, idVersion
const WHISKER_MAX_AGE_MS = 60000;
const WHISKER_COMPONENT_NAMES = [
    'any_hover', 'any_pointer', 'apple_pay', 'audio', 'audio_latency', 'browser_class', 'canvas',
    'canvas_readback', 'color_depth', 'color_gamut', 'color_scheme', 'content_blockers',
    'cpu_architecture', 'device_memory', 'dynamic_range', 'emoji_metrics', 'font_preferences',
    'fonts', 'fonts_canvas', 'forced_colors', 'hardware_concurrency', 'heap_limit', 'hover',
    'intl_locale', 'keyboard_layout', 'languages', 'math', 'media_codecs', 'noise_profile',
    'pdf_viewer', 'permissions', 'pixel_ratio', 'platform', 'plugins', 'pointer',
    'prefers_contrast', 'private_click', 'reduced_motion', 'screen_frame', 'screen_resolution',
    'speech_voices', 'storage', 'text_metrics', 'time_zone', 'touch_support', 'ua_client_hints',
    'ua_versions', 'vendor', 'vendor_flavors', 'webgl2_limits', 'webgl_image', 'webgl_params',
    'webgl_renderer', 'webgl_vendor', 'webgpu', 'webgpu_limits', 'wgsl_features',
];

/** A single-use nonce (128 random bits) and timestamp, kept in this session. */
function whisker_issue_nonce(): array
{
    $nonce = bin2hex(random_bytes(16));
    $timestamp = (int) floor(microtime(true) * 1000);
    $_SESSION['whisker_nonces'][$nonce] = $timestamp;
    return ['nonce' => $nonce, 'timestamp' => $timestamp];
}

/** The exact text the browser hashed (bindingInput in WhiskerJS). */
function whisker_binding_input(array $result): string
{
    $b = is_array($result['binding'] ?? null) ? $result['binding'] : [];
    $int = function ($v): int {
        if (!is_int($v)) {
            throw new UnexpectedValueException('not an integer');
        }
        return $v;
    };
    $lines = [
        'whiskerjs/bind/2',
        'nonce=' . ($b['nonce'] ?? ''),
        'timestamp=' . (isset($b['timestamp']) ? $int($b['timestamp']) : ''),
        'version=' . $result['version'],
        'signalsVersion=' . $result['signalsVersion'],
        'idVersion=' . $result['idVersion'],
        'visitorId=' . ($result['visitorId'] ?? '!'),
        'structuredId=' . ($result['structuredId'] ?? '!'),
        'previousSignalsVersion=' . ($result['previousSignalsVersion'] ?? '!'),
        'previousIdVersion=' . ($result['previousIdVersion'] ?? '!'),
        'previousVisitorId=' . ($result['previousVisitorId'] ?? '!'),
        'previousStructuredId=' . ($result['previousStructuredId'] ?? '!'),
        'durationMs=' . $int($result['durationMs']),
        'integrityFlags=' . implode(',', $result['integrityFlags']),
    ];
    $components = $result['components'];
    ksort($components, SORT_STRING);
    foreach ($components as $name => $c) {
        $hashed = $c['hashed'] ?? null;
        $kind = $hashed === true ? 'h' : ($hashed === false ? 'v' : '-');
        $lines[] = "c:{$name}={$c['status']}|{$kind}|" . ($c['value'] ?? '') . '|' . ($c['ruleId'] ?? '');
    }
    return implode("\n", $lines);
}

/** Null when the binding holds, otherwise the reason it does not. */
function whisker_verify_binding($result, ?int $nowMs = null): ?string
{
    $nowMs ??= (int) floor(microtime(true) * 1000);
    $b = is_array($result) ? ($result['binding'] ?? null) : null;
    if (!is_array($b) || !is_string($b['nonce'] ?? null) || !is_string($b['digest'] ?? null)
        || !preg_match('/^[0-9a-f]{32}$/', $b['digest'])) {
        return 'no_binding';
    }
    // 1. A nonce issued to this session, used once: remove it before any slow work.
    $issuedAt = $_SESSION['whisker_nonces'][$b['nonce']] ?? null;
    unset($_SESSION['whisker_nonces'][$b['nonce']]);
    if ($issuedAt === null) {
        return 'unknown_or_used_nonce';
    }
    // 2. The timestamp we stored, and recent.
    if (($b['timestamp'] ?? null) !== $issuedAt) {
        return 'timestamp_mismatch';
    }
    if ($nowMs - $issuedAt > WHISKER_MAX_AGE_MS) {
        return 'expired';
    }
    // 3. The version we ship, with exactly its 57 components.
    if ([$result['version'] ?? null, $result['signalsVersion'] ?? null, $result['idVersion'] ?? null] !== WHISKER_VERSIONS) {
        return 'unexpected_version';
    }
    if (!is_array($result['components'] ?? null)) {
        return 'unexpected_components';
    }
    $names = array_map('strval', array_keys($result['components']));
    sort($names, SORT_STRING);
    if ($names !== WHISKER_COMPONENT_NAMES) {
        return 'unexpected_components';
    }
    // 4. The digest matches the result as received.
    try {
        $text = whisker_binding_input($result);
    } catch (Throwable $e) {
        return 'bad_shape';
    }
    $expected = substr(hash('sha256', $text), 0, 32);
    return hash_equals($expected, $b['digest']) ? null : 'digest_mismatch';
}
```

`whisker_verify_binding` returns `null` when the binding holds, or the reason it does not. It expects the session to be started. Pass `$nowMs` only in tests.

Points that matter for an exact match with the browser:

- **Decode to arrays.** Use `json_decode($raw, true)`, so components are arrays and `hashed` stays `true`, `false` or missing.
- **Integers.** `timestamp` and `durationMs` must be PHP `int` values; on 64-bit PHP a millisecond timestamp fits.
- **The previous ids.** Since `whiskerjs/bind/2` the four fields `previousSignalsVersion`, `previousIdVersion`, `previousVisitorId` and `previousStructuredId` follow `structuredId`; each prints `!` when null (all four are null in a release that computes no previous ids).
- **Sorting.** `ksort($components, SORT_STRING)` orders names by bytes, as JavaScript’s default sort does for these ASCII names.
- **Constant time.** `hash_equals` compares the digests.

## A router for the built-in server

`router.php` serves the two endpoints with `php -S localhost:3000 -t public router.php`. In your application these are two routes; the last line only lets the built-in server deliver the example page from `public/`.

```php
<?php
// Routes for PHP's built-in server: php -S localhost:3000 -t public router.php
// In your app, these are two routes of your framework.
declare(strict_types=1);
require __DIR__ . '/whisker_binding.php';

$path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);

function send(int $status, array $body): void
{
    http_response_code($status);
    $json = json_encode($body);
    header('content-type: application/json');
    header('content-length: ' . strlen($json));
    echo $json;
}

if ($path === '/api/whisker-nonce' && $_SERVER['REQUEST_METHOD'] === 'GET') {
    session_start(['cookie_httponly' => true, 'cookie_samesite' => 'Lax']);
    send(200, whisker_issue_nonce());
    return true;
}

if ($path === '/api/login' && $_SERVER['REQUEST_METHOD'] === 'POST') {
    session_start(['cookie_httponly' => true, 'cookie_samesite' => 'Lax']);
    if ((int) ($_SERVER['CONTENT_LENGTH'] ?? 0) > 64 * 1024) { // a result is about 6.5 KB
        send(413, ['error' => 'too_large']);
        return true;
    }
    $raw = file_get_contents('php://input');
    $body = json_decode((string) $raw, true);
    $reason = whisker_verify_binding(is_array($body) ? ($body['device'] ?? null) : null);
    if ($reason !== null) {
        send(400, ['error' => $reason]);
        return true;
    }
    // Fresh and unchanged since collection. Now check the password and use
    // $body['device'] as one piece of evidence among others.
    send(200, ['visitorId' => $body['device']['visitorId'], 'flags' => $body['device']['integrityFlags']]);
    return true;
}

return false; // static files (this example only)
```

## What the digest proves

The binding digest is the first 128 bits of SHA-256 over a fixed text built from the whole result, your nonce and your timestamp. When the server rebuilds that text from what it received and gets the same digest, and the nonce is one it issued to this session, unused and recent, then:

- **it proves** the result was made in a browser that held your nonce, within the time window, and was not changed afterwards (a changed value, status, flag or id gives a different digest; the per-component timings and revisions are not covered);
- **it stops** replay: a recorded result cannot be sent again, because its nonce is used up, and an old one fails the age check;
- **it does not prove** that the browser told the truth. The digest is **not a signature**: code in the page can compute it too, so an attacker who controls the browser can build a fake result for a fresh nonce. Signing belongs on your server, where the key is safe.

So a passing binding means “fresh and unchanged”, nothing more. Use the result as evidence among other evidence; see [what never to trust from the client](https://whiskerjs.com/docs/integration/#what-never-to-trust-from-the-client).

## The checks, in order

| Step | Check | Refusal |
| --- | --- | --- |
| 1 | `binding` exists with a string nonce and a 32-hex digest | `no_binding` |
| 2 | the nonce was issued by this server, is unused, and is **deleted now**, before any slow work | `unknown_or_used_nonce` |
| 3 | the nonce was issued to this session | `wrong_session` |
| 4 | the timestamp equals the one stored with the nonce (never trust the one in the result) | `timestamp_mismatch` |
| 5 | issued at most 60 seconds ago (tune to your form) | `expired` |
| 6 | `version`, `signalsVersion`, `idVersion` are the ones you ship | `unexpected_version` |
| 7 | `components` has exactly the 57 names of signalsVersion 11 (since library PR #29; 47 before it) | `unexpected_components` |
| 8 | the rebuilt text hashes to the received digest, compared in constant time | `digest_mismatch` |

Deleting the nonce in step 2, before anything else, means two parallel requests with the same result cannot both pass.

## The text that is hashed

The browser hashes these lines joined with `\n` (UTF-8), and the server rebuilds them from the result it received:

```text
whiskerjs/bind/2
nonce=<nonce>
timestamp=<timestamp>
version=<version>
signalsVersion=<signalsVersion>
idVersion=<idVersion>
visitorId=<visitorId, or ! when null>
structuredId=<structuredId, or ! when null>
previousSignalsVersion=<previousSignalsVersion, or ! when null>
previousIdVersion=<previousIdVersion, or ! when null>
previousVisitorId=<previousVisitorId, or ! when null>
previousStructuredId=<previousStructuredId, or ! when null>
durationMs=<durationMs>
integrityFlags=<flags joined with ,>
c:<name>=<status>|<h, v or ->|<value or empty>|<ruleId or empty>
```

The last line repeats once per component, names sorted (byte order). The middle field is `h` for `hashed: true`, `v` for `hashed: false` and `-` when `hashed` is absent. `timestamp` and `durationMs` are integers and must print as plain digits. The format is described in [Anti-replay binding](https://whiskerjs.com/docs/integration/#the-digest).

## Tested

Tested with WhiskerJS 0.1.0 (signalsVersion 11, idVersion 2, binding text `whiskerjs/bind/2`) on 2026-10-27, the code exactly as printed on this page:

- the functions with PHP 8.3.36 (CLI, Docker image php:8.3-cli, `php -l` clean), nonces issued by `whisker_issue_nonce` into the session and the result decoded with `json_decode($raw, true)`, against a result made by the library at main commit a5bde97 (signalsVersion 11, idVersion 2, 57 components with their timings and revisions, in Node 22.23.2 with the library’s own test browser, with a binding and previous ids from signalsVersion 10) on 2026-10-27: accepted; the same result again refused with `unknown_or_used_nonce`; a changed `previousVisitorId` refused with `digest_mismatch`; a check 61 seconds after issue refused with `expired`;
- `router.php` on PHP’s built-in server with the [plain JavaScript page](https://whiskerjs.com/guides/javascript/) in headless Chromium 140 (Playwright 1.55.0), at signalsVersion 5 (binding text `whiskerjs/bind/1`): the login was accepted, and posting the same result again was refused with `unknown_or_used_nonce`. The server code is unchanged since; only the functions changed.

## Questions and answers

### Why keep nonces in $_SESSION?

The session already belongs to one visitor, so a nonce stored there cannot be used by another, and PHP's default file sessions are locked while a request runs, so reading and removing a nonce cannot race. With a custom session handler that does not lock, keep nonces in your database instead.

### Does it work with Laravel or Symfony?

The functions have no framework code. Call whisker_issue_nonce in your nonce route and whisker_verify_binding in your login route with the decoded JSON. With a framework session object instead of $_SESSION, change the two lines that read and write the nonce. Only the plain PHP version was tested.

### Why is_int and not a cast?

A cast would accept 831.5 or '831' and print something the browser never hashed. Requiring a real integer from json_decode keeps the rebuilt text identical.

## Related pages

- [Verify in Python](https://whiskerjs.com/guides/verify-python/): The same checks in Python.
- [Verify in Node](https://whiskerjs.com/guides/verify-node/): The same checks with the library's own code.
- [Plain JavaScript](https://whiskerjs.com/guides/javascript/): The page this server was tested with.
- [Anti-replay binding](https://whiskerjs.com/docs/integration/#anti-replay-binding): The format in detail.
