# 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.

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

> **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](https://whiskerjs.com/guides/javascript/#install-build-from-source).

## The module

`whisker_binding.py`:

```python
"""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/`.

```python
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](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 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:

```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 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](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 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.

## Related pages

- [Verify in Node](https://whiskerjs.com/guides/verify-node/): The same checks with the library's own code.
- [Verify in PHP](https://whiskerjs.com/guides/verify-php/): The same checks in PHP.
- [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.
