Docs · WhiskerJS 0.1.0 · signalsVersion 5 · idVersion 2

Integration

Getting the result to your server, checking it there, and the anti-replay binding.

In plain words

WhiskerJS hands you a JSON object and stops there. You add it to a request your page already makes (a login or checkout form), and your server checks it. If you want to stop someone from reusing an old result, your server gives the page a one-time code (a nonce) first; WhiskerJS ties the result to that code, and your server checks the code was used once and recently. Everything from the browser can be faked by a determined attacker, so treat the result as one piece of evidence.

WhiskerJS ends where your code begins: it gives you a result object and does nothing else with it. This page covers getting that result to your server and using it safely.

Sending the result

Add the result to a request you already make. There is no WhiskerJS endpoint and no extra request is needed.

import { load } from 'whiskerjs';

const agent = await load();

form.addEventListener('submit', async (event) => {
  event.preventDefault();
  const binding = await (await fetch('/api/whisker-nonce')).json();   // optional
  const device = await agent.collect({ timeoutMs: 3000, binding });
  await fetch('/api/login', {
    method: 'POST',
    headers: { 'content-type': 'application/json' },
    body: JSON.stringify({ email: form.email.value, password: form.password.value, device }),
  });
});

Tips:

  • Collect at the moment you need it (login, sign-up, checkout), not on every page view.
  • Pick timeoutMs for the moment: a few seconds before a form post is usually fine; collect() resolves as soon as every component settles.
  • The result is about 4 KB of JSON. If you verify the binding, send it unchanged: the digest covers every field.

The onResult hook

load({ onResult }) gives you one place where every result passes, for example to forward it to your own collector. It is off unless you pass a function.

const agent = await load({
  onResult: (result) => {
    navigator.sendBeacon('/api/device', JSON.stringify(result)); // your code, your endpoint
  },
});

Behaviour, exactly:

  • it is called once per collect(), with a deep copy (via JSON) of the result, just before collect() resolves; changing the copy does not change what collect() returns;
  • it is not awaited: an async hook runs on its own;
  • if it throws or its promise rejects, the error is swallowed and collect() is not affected;
  • a hook that does slow synchronous work still runs on the page’s main thread before collect() resolves, so keep it short or move the work into a promise;
  • a value that is not a function is ignored.

Storing ids

Store, per event (login, payment and so on):

Field Why
visitorId the exact-match key
structuredId similarity across small changes
idVersion, signalsVersion, version ids are only comparable within the same versions
integrityFlags what the checks saw at that moment
the components you use in rules for explanations and later analysis
your own context: account, time, IP address, user agent from the request the server-side view to compare with

Index on (idVersion, signalsVersion, visitorId). When you ship a new library version, expect new ids for the same devices and link them through a session you already trust. See Identifiers.

Keep retention short and limited to the purpose; see Privacy.

  1. Shape: the body parses, the versions are ones you ship, every name in components is in COMPONENT_NAMES and none is missing, each status is one of the six, values are at most 128 printable ASCII characters.
  2. Binding (if you use it): nonce issued by you, unused, recent, digest matches. See Anti-replay binding.
  3. Consistency with the request: compare the result with what your server sees itself. A ua_client_hints platform of Windows with a Mac user agent header, a time_zone far from the IP address’s region, or languages that do not match Accept-Language are all worth a look.
  4. Integrity flags: use them as inputs, weigh them, and remember the false positives listed in Integrity checks.
  5. History: the same visitorId on many accounts, or one account on many visitorIds in a short time, is usually more telling than any single result.

What never to trust from the client

Everything in the result was produced by code running on a machine you do not control. In particular:

  • Any value can be faked. A determined attacker can send a perfect result made by hand. The checks make faking harder and catch careless tools; they do not make it impossible.
  • A missing flag proves nothing. A clean integrityFlags: [] can be typed by hand.
  • The binding digest is not a signature. It stops replay of an old result and catches edits; it does not prove the browser was honest.
  • browser_class is a guess from client values.
  • durationMs is measured on the client clock.

So: use the result as evidence among other evidence, never as an authentication factor on its own, and never let it alone grant access or money.

Anti-replay binding

The problem

A result is just JSON. Without more, someone could record one good result and send it again and again, from another machine, long after it was made.

The idea

Your server makes a fresh, single-use nonce and notes the time it issued it. The page passes both into collect(). WhiskerJS copies them into the result and computes a digest over the whole result including them. Your server then checks: “is this my nonce, unused, and recent? Does the digest still match the result?”

server                       browser                        server
------                       -------                        ------
issue nonce, timestamp  ->   collect({ binding })     ->   check nonce unused, recent,
remember (nonce, time)       result.binding =               recompute the digest,
                             { nonce, timestamp, digest }   mark nonce used

This stops an old result from being replayed and catches accidental edits. It is not a signature: code in the page can recompute the digest, so it does not stop someone who builds a fresh fake result in their own browser. Signing belongs on your server, where the key is safe.

In the page

const { nonce, timestamp } = await (await fetch('/api/whisker-nonce')).json();
const result = await agent.collect({ binding: { nonce, timestamp } });
// result.binding: { nonce, timestamp, digest: '9b161c7e6b82c54427df82df3db99457' }

Input rules:

  • nonce: a string of 8 to 128 printable ASCII characters without spaces (^[\x21-\x7e]{8,128}$). Use at least 128 random bits, for example 32 hex characters from a cryptographic random source.
  • timestamp: an integer number of milliseconds from 0 to 8.64e15, from your server’s clock. WhiskerJS reads no clock and no randomness of its own for the binding.

What result.binding can be:

Case result.binding
no binding passed null
valid input { nonce, timestamp, digest }
not an object { nonce: null, timestamp: null, digest: null, ruleId: 'invalid_binding' }
bad nonce { nonce: null, timestamp: null, digest: null, ruleId: 'invalid_nonce' }
bad timestamp { nonce: null, timestamp: null, digest: null, ruleId: 'invalid_timestamp' }
no Web Crypto { nonce, timestamp, digest: null, ruleId: 'no_digest' }

The digest

The digest is the first 128 bits of SHA-256, as 32 lowercase hex characters, over this text (lines joined with \n, UTF-8):

whiskerjs/bind/1
nonce=<nonce>
timestamp=<timestamp>
version=<version>
signalsVersion=<signalsVersion>
idVersion=<idVersion>
visitorId=<visitorId, or ! when null>
structuredId=<structuredId, 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. The middle field is h for hashed: true, v for hashed: false and - when hashed is absent. bindingInput(result) (exported) returns exactly this text, so your server does not have to rebuild it by hand.

On the server

A sketch for Node 18 or newer. It uses the library’s own bindingInput (pure code, no browser needed) and checks the result’s shape before trusting anything in it.

import { createHash, randomBytes } from 'node:crypto';
import { bindingInput, COMPONENT_NAMES, type CollectResult } from 'whiskerjs';

const MAX_AGE_MS = 60_000;
const issued = new Map<string, number>(); // use your database or cache, with expiry

export function issueNonce(): { nonce: string; timestamp: number } {
  const nonce = randomBytes(16).toString('hex');
  const timestamp = Date.now();
  issued.set(nonce, timestamp);
  return { nonce, timestamp };
}

export function verify(result: CollectResult): { ok: boolean; reason?: string } {
  const b = result?.binding;
  if (!b || typeof b.nonce !== 'string' || typeof b.digest !== 'string') {
    return { ok: false, reason: 'no_binding' };
  }
  // 1. The nonce is one we issued, and it is used only once.
  const issuedAt = issued.get(b.nonce);
  if (issuedAt === undefined) return { ok: false, reason: 'unknown_or_used_nonce' };
  issued.delete(b.nonce);
  // 2. The timestamp is the one we issued, and it is recent.
  if (b.timestamp !== issuedAt) return { ok: false, reason: 'timestamp_mismatch' };
  if (Date.now() - issuedAt > MAX_AGE_MS) return { ok: false, reason: 'expired' };
  // 3. The result has exactly the components this library version reports.
  const names = Object.keys(result.components ?? {}).sort();
  if (names.join(',') !== [...COMPONENT_NAMES].sort().join(',')) {
    return { ok: false, reason: 'unexpected_components' };
  }
  // 4. The digest matches the result as received.
  const expected = createHash('sha256').update(bindingInput(result), 'utf8').digest('hex').slice(0, 32);
  if (expected !== b.digest) return { ok: false, reason: 'digest_mismatch' };
  return { ok: true };
}

Notes:

  • Mark the nonce used before any slow work, so two parallel requests with the same result cannot both pass.
  • Compare the timestamp with what you stored rather than trusting the one in the result.
  • Check version, signalsVersion and idVersion against the versions you ship; step 3 above assumes the server runs the same library version as the page.
  • Bind the nonce to the session or form it was issued for, so a nonce issued to one visitor cannot be used by another.
  • The digest only proves the result was not changed after it was made in a browser holding that nonce. Whether the browser itself told the truth is a separate question; see Integration.