Guides · WhiskerJS 0.1.0

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.

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.

The functions

whisker_binding.php:

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

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

/** 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 47 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
// 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 4 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);
  • 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.

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 47 names of signalsVersion 9 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:

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.

Tested

Tested with WhiskerJS 0.1.0 (signalsVersion 9, idVersion 2, binding text whiskerjs/bind/2) on 2026-10-26:

  • the functions with PHP 8.3.36 (CLI, Docker image php:8.3-cli) against a result made by the library at commit 3618012 (in Node 22 with the library’s own test browser, with a binding and previous ids): 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 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.