# Account takeover

> A correct password from an unfamiliar device: how device signals help your server notice it, and what to do next.

Source: https://whiskerjs.com/use-cases/account-takeover/ (last updated 2026-10-26)

> **In plain words:**
>
> 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:

```ts
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 `visitorId` match with a device this account used before (same `idVersion` and `signalsVersion`).
- **Locale against network**: [`time_zone`](https://whiskerjs.com/signals/time-zone/) and [`languages`](https://whiskerjs.com/signals/languages/) against the IP address region and `Accept-Language`. A change can also mean the user is travelling.
- **Platform against headers**: [`ua_client_hints`](https://whiskerjs.com/signals/ua-client-hints/) and [`platform`](https://whiskerjs.com/signals/platform/) against the User-Agent header.
- **Automation**: [`automation_webdriver`](https://whiskerjs.com/docs/integrity/#automation_webdriver), [`automation_globals`](https://whiskerjs.com/docs/integrity/#automation_globals), [`headless_markers`](https://whiskerjs.com/docs/integrity/#headless_markers) and [`headless_user_agent`](https://whiskerjs.com/docs/integrity/#headless_user_agent) are typical of stuffing tools. Monitoring robots and your own tests set them too.
- **Spoofing**: [`iframe_mismatch`](https://whiskerjs.com/docs/integrity/#iframe_mismatch), [`navigator_own_property`](https://whiskerjs.com/docs/integrity/#navigator_own_property) and [`prototype_lie`](https://whiskerjs.com/docs/integrity/#prototype_lie) suggest 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:

```ts
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](https://whiskerjs.com/docs/identifiers/#comparing-two-results), 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](https://whiskerjs.com/docs/integration/#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

1. Check the password as usual. Device signals never replace it.
2. Verify the result’s shape and the binding.
3. Look the `visitorId` up in the account’s device history; if not found, compare the `structuredId` with known devices.
4. Combine with IP reputation, request headers, the time since the last login and the failed attempts on this account and from this network.
5. 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.
6. 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 under `previousSignalsVersion` and `previousIdVersion` and add the new id to the device you find (see [previous ids](https://whiskerjs.com/docs/identifiers/#previous-ids-linking-across-a-version-bump)). 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.

## Related pages

- [Fraud prevention](https://whiskerjs.com/use-cases/fraud-prevention/): The general pattern behind every use case.
- [Bot and headless detection](https://whiskerjs.com/use-cases/bot-detection/): Credential-stuffing tools and the flags they leave.
- [What changes the id](https://whiskerjs.com/docs/identifiers/#what-changes-the-id): Updates, fonts, settings and private windows.
- [Verify the binding in Node](https://whiskerjs.com/guides/verify-node/): Server-side check of nonce, timestamp and digest.
