Guides · WhiskerJS 0.1.0

Verify the anti-replay binding in Python

A standard-library Python module that issues nonces and checks that a WhiskerJS result is fresh and unchanged, plus a small http.server example.

In plain words

Your Python server hands out a random one-time code with the time before the form is submitted. WhiskerJS seals that code into its result in the browser. When the form arrives, the server checks that it issued the code to this visitor, that the code is unused and recent, and that the seal still matches every field of the result.

Install

Nothing to install on the server: the module uses only the Python standard library (Python 3.10 or newer for the type hints used). 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 module

whisker_binding.py:

"""Check the anti-replay binding of a WhiskerJS result (WhiskerJS 0.1.0,
signalsVersion 9, idVersion 2). Standard library only."""
import hashlib
import hmac
import re
import secrets
import threading
import time

VERSION, SIGNALS_VERSION, ID_VERSION = "0.1.0", "9", "2"
MAX_AGE_MS = 60_000

# The 47 components of signalsVersion 9.
COMPONENT_NAMES = frozenset("""
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
""".split())

DIGEST = re.compile(r"[0-9a-f]{32}")
# Since whiskerjs/bind/2 the binding covers the previous ids, in this order.
PREVIOUS_FIELDS = ("previousSignalsVersion", "previousIdVersion", "previousVisitorId", "previousStructuredId")

# nonce -> (timestamp, session id). In production use your database or cache, with expiry.
_issued: dict[str, tuple[int, str]] = {}
_lock = threading.Lock()


def remember(nonce: str, timestamp: int, session_id: str) -> None:
    with _lock:
        _issued[nonce] = (timestamp, session_id)


def issue_nonce(session_id: str) -> dict:
    """A single-use nonce (128 random bits) and timestamp for this session."""
    nonce = secrets.token_hex(16)
    timestamp = int(time.time() * 1000)
    remember(nonce, timestamp, session_id)
    return {"nonce": nonce, "timestamp": timestamp}


def _int(value) -> int:
    # JSON integers only: Python's bool is an int, and 831.0 would print differently.
    if type(value) is not int:
        raise ValueError("not an integer")
    return value


def binding_input(result: dict) -> str:
    """The exact text the browser hashed (bindingInput in WhiskerJS)."""
    b = result.get("binding") or {}
    nonce = b.get("nonce")
    timestamp = b.get("timestamp")
    lines = [
        "whiskerjs/bind/2",
        f"nonce={'' if nonce is None else nonce}",
        f"timestamp={'' if timestamp is None else _int(timestamp)}",
        f"version={result['version']}",
        f"signalsVersion={result['signalsVersion']}",
        f"idVersion={result['idVersion']}",
        f"visitorId={'!' if result.get('visitorId') is None else result['visitorId']}",
        f"structuredId={'!' if result.get('structuredId') is None else result['structuredId']}",
        *(f"{key}={'!' if result.get(key) is None else result[key]}" for key in PREVIOUS_FIELDS),
        f"durationMs={_int(result['durationMs'])}",
        f"integrityFlags={','.join(result['integrityFlags'])}",
    ]
    for name in sorted(result["components"]):
        c = result["components"][name]
        kind = "h" if c.get("hashed") is True else "v" if c.get("hashed") is False else "-"
        value = c.get("value")
        rule = c.get("ruleId")
        lines.append(f"c:{name}={c['status']}|{kind}|{'' if value is None else value}|{'' if rule is None else rule}")
    return "\n".join(lines)


def verify_binding(result, session_id: str, now_ms: int | None = None) -> str | None:
    """None when the binding holds, otherwise the reason it does not."""
    now_ms = int(time.time() * 1000) if now_ms is None else now_ms
    b = result.get("binding") if isinstance(result, dict) else None
    if not isinstance(b, dict) or not isinstance(b.get("nonce"), str) \
            or not isinstance(b.get("digest"), str) or not DIGEST.fullmatch(b["digest"]):
        return "no_binding"
    # 1. A nonce we issued to this session, used once: remove it before any slow work.
    with _lock:
        entry = _issued.pop(b["nonce"], None)
    if entry is None:
        return "unknown_or_used_nonce"
    issued_at, owner = entry
    if owner != session_id:
        return "wrong_session"
    # 2. The timestamp we stored, and recent.
    if type(b.get("timestamp")) is not int or b["timestamp"] != issued_at:
        return "timestamp_mismatch"
    if now_ms - issued_at > MAX_AGE_MS:
        return "expired"
    # 3. The version we ship, with exactly its 47 components.
    if (result.get("version"), result.get("signalsVersion"), result.get("idVersion")) != (VERSION, SIGNALS_VERSION, ID_VERSION):
        return "unexpected_version"
    components = result.get("components")
    if not isinstance(components, dict) or set(components) != COMPONENT_NAMES:
        return "unexpected_components"
    # 4. The digest matches the result as received.
    try:
        text = binding_input(result)
    except (KeyError, TypeError, ValueError, AttributeError):
        return "bad_shape"
    expected = hashlib.sha256(text.encode("utf-8")).hexdigest()[:32]
    if not hmac.compare_digest(expected, b["digest"]):
        return "digest_mismatch"
    return None

verify_binding returns None when the binding holds, or the reason it does not. Pass now_ms only in tests.

Points that matter for an exact match with the browser:

  • Integers. timestamp and durationMs must be real int values (not bool, not float), so they print as plain digits.
  • Missing versus empty. c.get("hashed") is True gives h, is False gives v, anything else -; a missing value or ruleId prints as empty.
  • 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. sorted() orders the ASCII names the same way JavaScript’s default sort does.
  • Constant time. hmac.compare_digest compares the digests.

A complete server

server.py uses http.server only, to stay dependency-free. It sets a random sid cookie as the session, serves the nonce and verifies the login post before anything else. In your framework these are two views; the static file serving exists only for the example page in public/.

import json
import re
import secrets
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer

from whisker_binding import issue_nonce, verify_binding

MAX_BODY = 64 * 1024  # a result is about 4 KB


class Handler(SimpleHTTPRequestHandler):
    def __init__(self, *args, **kwargs):
        # Static files for this example only; your framework serves its own.
        super().__init__(*args, directory="public", **kwargs)

    def session(self) -> str:
        match = re.search(r"(?:^|;\s*)sid=([0-9a-f]{32})", self.headers.get("cookie", ""))
        if match:
            self.new_sid = None
            return match.group(1)
        self.new_sid = secrets.token_hex(16)
        return self.new_sid

    def send(self, status: int, body: dict) -> None:
        data = json.dumps(body).encode()
        self.send_response(status)
        self.send_header("content-type", "application/json")
        if self.new_sid:
            self.send_header("set-cookie", f"sid={self.new_sid}; HttpOnly; SameSite=Lax; Path=/")
        self.send_header("content-length", str(len(data)))
        self.end_headers()
        self.wfile.write(data)

    def do_GET(self):
        if self.path == "/api/whisker-nonce":
            return self.send(200, issue_nonce(self.session()))
        return super().do_GET()

    def do_POST(self):
        if self.path != "/api/login":
            return self.send_error(404)
        sid = self.session()
        length = int(self.headers.get("content-length", 0))
        if length > MAX_BODY:
            return self.send(413, {"error": "too_large"})
        try:
            body = json.loads(self.rfile.read(length))
        except ValueError:
            return self.send(400, {"error": "bad_request"})
        reason = verify_binding(body.get("device") if isinstance(body, dict) else None, sid)
        if reason:
            return self.send(400, {"error": reason})
        # Fresh and unchanged since collection. Now check the password and use
        # body["device"] as one piece of evidence among others.
        device = body["device"]
        return self.send(200, {"visitorId": device["visitorId"], "flags": device["integrityFlags"]})


ThreadingHTTPServer(("localhost", 3000), Handler).serve_forever()

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 module with Python 3.13.0 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;
  • the server with Python 3.12.3 and 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 module changed.

Questions and answers

Can I use this with Django, Flask or FastAPI?

Yes. The module has no web framework code: call issue_nonce from your nonce view and verify_binding from your login view with the parsed JSON body and your session id. The example server uses http.server only to stay dependency-free; it was the setup that was tested.

Why check type(value) is int?

Python's bool is a subclass of int, and a float such as 831.0 would print as 831.0 instead of 831. Requiring a real int keeps the rebuilt text identical to what the browser hashed.

Is the in-memory dictionary safe?

Within one process, yes: a lock makes reading and deleting a nonce one step. With several workers or servers, keep nonces in a shared store such as your database or Redis, with an expiry.