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
timeoutMsfor 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 beforecollect()resolves; changing the copy does not change whatcollect()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.
Recommended server checks
- Shape: the body parses, the versions are ones you ship, every name in
componentsis inCOMPONENT_NAMESand none is missing, each status is one of the six, values are at most 128 printable ASCII characters. - Binding (if you use it): nonce issued by you, unused, recent, digest matches. See Anti-replay binding.
- Consistency with the request: compare the result with what your
server sees itself. A
ua_client_hintsplatform ofWindowswith a Mac user agent header, atime_zonefar from the IP address’s region, orlanguagesthat do not matchAccept-Languageare all worth a look. - Integrity flags: use them as inputs, weigh them, and remember the false positives listed in Integrity checks.
- History: the same
visitorIdon many accounts, or one account on manyvisitorIds 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_classis a guess from client values.durationMsis 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,signalsVersionandidVersionagainst 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.