In an account takeover someone else signs in with a real user’s password. The password is right, so the login looks normal. What often differs is the device. WhiskerJS lets your server see whether the device at this login looks like one the account has used before, and which part of it is new. Your server then decides whether to ask for more proof.
The problem
Stolen or reused passwords let an attacker sign in as the user. Common patterns are credential stuffing (trying leaked username and password pairs at scale, usually with automation) and targeted takeovers (one account, a careful attacker, often a real browser). In both, the server sees a valid password. The useful question becomes: does this device fit the account?
What WhiskerJS contributes
At the login submit, collect once and send the result with the login request:
import { load } from 'whiskerjs';
const agent = await load();
form.addEventListener('submit', async (event) => {
event.preventDefault();
const binding = await (await fetch('/api/whisker-nonce')).json();
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 }),
});
});
Your server then has, next to the password check, a visitorId to look up in the account’s history, a structuredId to compare with the devices it knows, and the integrityFlags of this login.
Signals and flags that matter
- Known device or not: an exact
visitorIdmatch with a device this account used before (sameidVersionandsignalsVersion). - Locale against network:
time_zoneandlanguagesagainst the IP address region andAccept-Language. A change can also mean the user is travelling. - Platform against headers:
ua_client_hintsandplatformagainst the User-Agent header. - Automation:
automation_webdriver,automation_globals,headless_markersandheadless_user_agentare typical of stuffing tools. Monitoring robots and your own tests set them too. - Spoofing:
iframe_mismatch,navigator_own_propertyandprototype_liesuggest values were overridden. Privacy extensions can cause the same.
Using structuredId similarity
A user’s own device changes over time. A browser update moves the browser group; a GPU driver update moves gpu; a trip moves locale. An unknown device usually differs in most groups. Comparing group by group lets your rule tell these apart:
import { ID_GROUP_ORDER } from 'whiskerjs';
/** Groups that differ between two structuredIds, or null when not comparable. */
function changedGroups(a: string, b: string): string[] | null {
const pa = a.split('_');
const pb = b.split('_');
if (pa[0] !== pb[0]) return null;
return ID_GROUP_ORDER.filter((_, i) => pa[i + 1] !== pb[i + 1]);
}
// changedGroups(knownDevice, thisLogin)
// ['browser'] -> reads like an update of a known device
// ['gpu', 'fonts', 'device', 'locale'] -> reads like a different device
These readings are hints from Identifiers, not rules. How many changed groups you accept, and which ones, is a choice to tune on your own logins.
Anti-replay
A takeover tool could replay a result recorded from the victim’s real browser. The binding prevents that: a nonce your server issued for this login, used once, recent, and a digest that still matches. A replayed result carries an old or unknown nonce. See Anti-replay binding. Bind the nonce to the login form or session it was issued for, so one visitor’s nonce cannot be used by another.
What must happen on your server
- Check the password as usual. Device signals never replace it.
- Verify the result’s shape and the binding.
- Look the
visitorIdup in the account’s device history; if not found, compare thestructuredIdwith known devices. - Combine with IP reputation, request headers, the time since the last login and the failed attempts on this account and from this network.
- Choose a proportionate response: allow, ask for a second factor, notify the user, or block. A new device alone is a reason to ask for more, rarely a reason to block.
- After a successful step-up, store the new device for the account, with both version numbers. When you ship a new WhiskerJS version, expect new ids: for one release after a bump the result carries
previousVisitorId, so look that up underpreviousSignalsVersionandpreviousIdVersionand add the new id to the device you find (see previous ids). Where that finds nothing, re-link through a session you already trust.
Limits
- A careful attacker in a real, clean browser can produce a perfectly ordinary result; the device check then only says “new device”.
- Users change devices, reinstall, use private windows or privacy browsers. Expect new ids from real users.
- Identical devices (a company fleet) can share an id, so a known id is not proof of a known person.
- No accuracy figures exist yet; measure your own false step-up rate before tightening rules.
Questions and answers
Should a new visitorId force a password reset?
Usually not on its own. A browser update or a new font can change the id. Compare the structuredId groups and your other signals first, then choose a proportionate step such as a second factor.
What if the user signs in from a privacy browser?
Under Brave, Firefox and Safari fingerprinting protection, Firefox resistFingerprinting and Tor Browser some components are excluded, so devices look more alike. That is expected; rely more on the account's history and your second factor there.
Can an attacker copy a victim's device result?
They can copy or build a result if they control a browser, which is why the binding stops replay of old results and why the device result should never be the only check.