Before the visitor submits a form, your server hands out a random one-time code with the time. WhiskerJS seals that code into its result. When the form arrives, the server checks three things: it issued this code to this visitor, the code has not been used and is recent, and the seal still matches the result. That stops anyone from recording a good result and sending it again later.
Install: build from source
WhiskerJS is not on npm yet; the npm package is coming. The server uses the same package as the page:
cd whiskerjs
pnpm install && pnpm build && pnpm pack # writes whiskerjs-0.1.0.tgz
cd ../my-server
npm install ../whiskerjs/whiskerjs-0.1.0.tgz
WhiskerJS is ES modules only, and the samples use import syntax; the tested project had "type": "module" in its package.json. Node 18 or newer runs the library; the TypeScript files below run directly on Node 22.18 or newer.
The verification module
whisker-binding.ts: issue a nonce, verify a result. It imports bindingInput, COMPONENT_NAMES and the version constants from WhiskerJS, so it always matches the library version you install.
import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
import {
bindingInput,
COMPONENT_NAMES,
ID_VERSION,
SIGNALS_VERSION,
VERSION,
type CollectResult,
} from 'whiskerjs';
const MAX_AGE_MS = 60_000;
const EXPECTED_NAMES = [...COMPONENT_NAMES].sort().join(',');
interface Issued {
timestamp: number;
sessionId: string;
}
// One entry per nonce. In production use your database or cache, with expiry.
export const issued = new Map<string, Issued>();
/** Make a single-use nonce for this session. Send both values to the page. */
export function issueNonce(sessionId: string): { nonce: string; timestamp: number } {
const nonce = randomBytes(16).toString('hex'); // 128 random bits
const timestamp = Date.now();
issued.set(nonce, { timestamp, sessionId });
return { nonce, timestamp };
}
/** ok, or the reason the binding does not hold. */
export interface Verdict {
ok: boolean;
reason?: string;
}
/** Check the binding of a result this session sent. Never trust anything else in it before this passes. */
export function verifyBinding(result: CollectResult, sessionId: string, now = Date.now()): Verdict {
const b = result?.binding;
if (!b || typeof b.nonce !== 'string' || typeof b.digest !== 'string' || !/^[0-9a-f]{32}$/.test(b.digest)) {
return { ok: false, reason: 'no_binding' };
}
// 1. A nonce we issued to this session, used once: delete it before any slow work.
const entry = issued.get(b.nonce);
if (!entry) return { ok: false, reason: 'unknown_or_used_nonce' };
issued.delete(b.nonce);
if (entry.sessionId !== sessionId) return { ok: false, reason: 'wrong_session' };
// 2. The timestamp we stored, and recent.
if (b.timestamp !== entry.timestamp) return { ok: false, reason: 'timestamp_mismatch' };
if (now - entry.timestamp > MAX_AGE_MS) return { ok: false, reason: 'expired' };
// 3. The library version we ship, with exactly its 47 components.
if (result.version !== VERSION || result.signalsVersion !== SIGNALS_VERSION || result.idVersion !== ID_VERSION) {
return { ok: false, reason: 'unexpected_version' };
}
if (!result.components || typeof result.components !== 'object') return { ok: false, reason: 'bad_shape' };
if (Object.keys(result.components).sort().join(',') !== EXPECTED_NAMES) {
return { ok: false, reason: 'unexpected_components' };
}
// 4. The digest matches the result as received.
let text: string;
try {
text = bindingInput(result);
} catch {
return { ok: false, reason: 'bad_shape' };
}
const expected = createHash('sha256').update(text, 'utf8').digest('hex').slice(0, 32);
if (!timingSafeEqual(Buffer.from(expected), Buffer.from(b.digest))) {
return { ok: false, reason: 'digest_mismatch' };
}
return { ok: true };
}
verifyBinding returns { ok: true } or { ok: false, reason }; the reasons are listed under the checks, in order. Pass now only in tests.
A complete server
server.ts uses only node:http. It sets a random sid cookie as the session (use your own session if you have one), serves the nonce, and verifies the binding of the login post before anything else. The static file part at the end exists only so the example page can be served from the same origin.
import { randomBytes } from 'node:crypto';
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
import { readFile } from 'node:fs/promises';
import { extname, join, normalize } from 'node:path';
import { issueNonce, verifyBinding } from './whisker-binding.ts';
const MAX_BODY = 64 * 1024; // a result is about 4 KB
function sessionOf(req: IncomingMessage, res: ServerResponse): string {
const match = /(?:^|;\s*)sid=([0-9a-f]{32})/.exec(req.headers.cookie ?? '');
if (match) return match[1];
const sid = randomBytes(16).toString('hex');
res.setHeader('set-cookie', `sid=${sid}; HttpOnly; SameSite=Lax; Path=/`);
return sid;
}
async function readJson(req: IncomingMessage): Promise<any> {
let size = 0;
const chunks: Buffer[] = [];
for await (const chunk of req) {
size += chunk.length;
if (size > MAX_BODY) throw new Error('too large');
chunks.push(chunk);
}
return JSON.parse(Buffer.concat(chunks).toString('utf8'));
}
function send(res: ServerResponse, status: number, body: unknown): void {
res.writeHead(status, { 'content-type': 'application/json' });
res.end(JSON.stringify(body));
}
const TYPES: Record<string, string> = { '.html': 'text/html', '.js': 'text/javascript' };
createServer(async (req, res) => {
const url = new URL(req.url ?? '/', 'http://localhost');
if (req.method === 'GET' && url.pathname === '/api/whisker-nonce') {
return send(res, 200, issueNonce(sessionOf(req, res)));
}
if (req.method === 'POST' && url.pathname === '/api/login') {
let body: any;
try {
body = await readJson(req);
} catch {
return send(res, 400, { error: 'bad_request' });
}
const verdict = verifyBinding(body?.device, sessionOf(req, res));
if (!verdict.ok) return send(res, 400, { error: verdict.reason });
// The result is fresh and unchanged since collection. Now check the password,
// and use body.device as one piece of evidence among others.
return send(res, 200, { visitorId: body.device.visitorId, flags: body.device.integrityFlags });
}
// Static files for this example only; your app or framework serves its own.
const path = normalize(join('public', url.pathname === '/' ? 'index.html' : url.pathname));
if (!path.startsWith('public')) return send(res, 404, { error: 'not_found' });
try {
const file = await readFile(path);
res.writeHead(200, { 'content-type': TYPES[extname(path)] ?? 'application/octet-stream' });
res.end(file);
} catch {
send(res, 404, { error: 'not_found' });
}
}).listen(3000, 'localhost');
Run it with node server.ts and open http://localhost:3000/ with the page from the plain JavaScript guide in public/.
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:
verifyBindingon Node 22.23.4 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 withunknown_or_used_nonce; a changedpreviousVisitorIdrefused withdigest_mismatch; a check 61 seconds after issue refused withexpired;server.tson Node 22.18.0 with the plain JavaScript page in headless Chromium 140 (Playwright 1.55.0), at signalsVersion 5 (binding textwhiskerjs/bind/1): the login was accepted, and posting the same result again was refused withunknown_or_used_nonce. The server code is unchanged since.
Questions and answers
Why import WhiskerJS on the server?
bindingInput and COMPONENT_NAMES are plain code with no browser dependency. Using them means the server rebuilds exactly the text the browser hashed, for the same library version.
Can I run the TypeScript files directly?
Node 22.18 and newer strip TypeScript types by default, so node server.ts works. On older Node versions compile them first, or rename them to .js and remove the type annotations.
Where should nonces be stored in production?
In a store every server process shares, such as your database or a cache, with an expiry of about a minute. The Map in the sample only works for one long-running process.
Why use timingSafeEqual for the digest?
A normal string comparison can stop at the first differing character, which in principle leaks how much of a guess was right. A constant-time comparison avoids that; it costs nothing here.