You copy the WhiskerJS files next to your page, import them with one line, and when the visitor presses the submit button you ask your server for a one-time code, let WhiskerJS read the browser, and send its answer together with the form. Nothing else on the page changes.
Install: build from source
WhiskerJS is not on npm yet; the npm package is coming. Build it from source:
cd whiskerjs # the WhiskerJS source folder
pnpm install && pnpm build # writes dist/: ES2020 modules and .d.ts files
Then pick one of two ways.
- As plain files. Copy
dist/*.jsanddist/collectors/*.jsto your static files, keeping the folder layout (for example topublic/vendor/whiskerjs/). The.d.tsfiles are only for editors and type checkers; leave them out if you like. This is how the live demo runs it. - With a bundler. Run
pnpm packin the WhiskerJS folder and install the tarball in your project withnpm install ../whiskerjs/whiskerjs-0.1.0.tgz. Your bundler then resolvesimport { load } from 'whiskerjs'.
There are no runtime dependencies.
The page, as ES modules
A complete sign-in page. It assumes the two endpoints described in the guides overview: GET /api/whisker-nonce and your existing POST /api/login.
<!doctype html>
<html lang="en">
<head><meta charset="utf-8"><title>Sign in</title></head>
<body>
<form id="login">
<label>Email <input name="email" type="email" required></label>
<label>Password <input name="password" type="password" required></label>
<button type="submit">Sign in</button>
</form>
<output id="status"></output>
<script type="module">
import { load } from '/vendor/whiskerjs/index.js';
const agentReady = load(); // cheap: no collection work happens yet
const form = document.getElementById('login');
const status = document.getElementById('status');
form.addEventListener('submit', async (event) => {
event.preventDefault();
try {
// 1. A fresh nonce and timestamp from your server.
const binding = await (await fetch('/api/whisker-nonce')).json();
// 2. Collect at the moment you need it. collect() never throws or rejects.
const agent = await agentReady;
const device = await agent.collect({ timeoutMs: 3000, binding });
// 3. Send it with the request you already make.
const response = await fetch('/api/login', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ email: form.email.value, password: form.password.value, device }),
});
status.textContent = response.ok ? 'Signed in' : `Refused (${response.status})`;
} catch {
status.textContent = 'Network error, please try again';
}
});
</script>
</body>
</html>
What happens on submit:
- Nonce. The page asks your server for a fresh
{ nonce, timestamp }. The server stores both for this session; see the Node, Go, PHP or Python guide. - Collect.
collect({ timeoutMs: 3000, binding })reads the 47 components, runs the integrity checks, derives the ids and addsresult.bindingwith your nonce, your timestamp and a digest over the whole result. It never throws and never rejects; a component that fails reports its own status instead. - Send. The result goes in the body of the request you were sending anyway, as the
devicefield. There is no WhiskerJS endpoint and no extra request.
The try block is for the two fetch calls, which can fail like any request.
The same page with a bundler
With the tarball installed, only the import changes. The script moves to its own file:
import { load } from 'whiskerjs';
const agentReady = load(); // cheap: no collection work happens yet
const form = document.getElementById('login');
const status = document.getElementById('status');
form.addEventListener('submit', async (event) => {
event.preventDefault();
try {
// 1. A fresh nonce and timestamp from your server.
const binding = await (await fetch('/api/whisker-nonce')).json();
// 2. Collect at the moment you need it. collect() never throws or rejects.
const agent = await agentReady;
const device = await agent.collect({ timeoutMs: 3000, binding });
// 3. Send it with the request you already make.
const response = await fetch('/api/login', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ email: form.email.value, password: form.password.value, device }),
});
status.textContent = response.ok ? 'Signed in' : `Refused (${response.status})`;
} catch {
status.textContent = 'Network error, please try again';
}
});
and the page loads it with <script type="module" src="/src/main.js"></script> (the path your bundler expects).
Things to know
- Secure context. Serve the page over https, or from
http://localhostwhile you develop. Hashing usescrypto.subtle, which browsers only offer there; without itvisitorIdandstructuredIdarenulland large components areunavailablewith ruleIdno_digest. - A body element. Several probes add hidden elements to
document.bodyfor a moment and remove them beforecollect()resolves. A module script runs after the document is parsed, so the body exists; if you callcollect()from a script in the<head>withouttype="module"ordefer, wait forDOMContentLoaded. - Content Security Policy. WhiskerJS makes no network request and creates no worker, so it needs nothing beyond
script-srcfor your own files. Its integrity checks add oneabout:blankiframe without asrcfor a moment. - When to collect. On the action you protect (sign-in, sign-up, checkout), not on every page view. The time it takes is in
result.durationMs; the overall budget istimeoutMs. - What the result contains. See Getting started and Signals.
Tested
Tested with WhiskerJS 0.1.0 (signalsVersion 5, idVersion 2) and headless Chromium 140 (Playwright 1.55.0) on 2026-10-26: the ES module page served by the Node server, and the bundler version built with Vite 8.3.5. In both, the server accepted the binding and refused the same result sent a second time.
Questions and answers
Do I need a bundler?
No. The dist folder is plain ES2020 modules with relative imports, so a browser can load index.js directly with a script of type module. A bundler only changes the import line.
Can I load it from a CDN?
No CDN serves WhiskerJS. Serve the files from your own origin, which also keeps the page free of third-party requests.
Why is the agent created before the click?
load() is cheap: it only picks the browser objects, the clock and Web Crypto, and does no collection work. Creating it early means the submit handler only waits for collect().
What if the nonce request fails?
Then fetch throws and the catch block reports a network error. collect() itself never throws, so the try block is only there for your own requests.