Skip to main content
Account takeover is a known, legitimate account suddenly accessed by someone else: the right password from the wrong place. The shape on the wire is a UserHID you have seen many times before, arriving on a DeviceID you have never seen for it, often from a new country or behind a datacenter, VPN, or Tor session. ShieldLabs gives you the durable DeviceID to recognize the device and the Risk Score to read the session’s anonymity, so your login code can step up to a second factor exactly when the device or location does not fit the account.

What is account takeover (ATO)?

Account takeover (ATO) is fraud where an attacker gains unauthorized access to a legitimate user’s account, usually with stolen or leaked credentials, then uses it to drain funds, make purchases, or harvest data. Because the password is correct, the login passes every credential check and only the device and session context give it away.

How ShieldLabs surfaces it

A first-time login looks the same to your password check whether it is the real owner on a new laptop or an intruder with a stolen password. The difference is in the history. ShieldLabs returns a durable DeviceID for the machine in front of you — derived server-side from stable device characteristics, so it stays the same when the visitor clears cookies, opens an incognito window, or rotates their IP, and an intruder on a different machine cannot reproduce it. A UserHID that has only ever appeared on one or two DeviceIDs, now logging in from a third, is the core takeover shape. The per-request Risk Score (0–100) reads the session’s anonymity on top, folding Datacenter IP, VPN, Proxy, Tor, Privacy Relay, anti-detect browser, and timezone-mismatch signals into one number. Even when an intruder fakes a familiar public_ip.country over a VPN, the real network IP (local_ip) can expose the network behind the mask, and their disagreement surfaces as the ip_mismatch flag. Over time the dashboard grades the New Device and New Country pattern, the closest standing view of the takeover shape.
This tutorial is the device-and-location half of login security. The step-up 2FA tutorial owns the threshold ladder that turns a risky login into a second-factor challenge, and the credential stuffing tutorial owns throttling the flood of attempts by DeviceID. This page assumes both and only carries the device-comparison logic that is unique to takeover.

Stop account takeover at login

After your password check passes, read the login’s DeviceID and Risk Score and compare the device and country against the ones this UserHID has used before. The rule your code applies: a new device and a new country, or a new device and datacenter or Tor signals on the session, escalates to a second factor; a strong environment signal escalates on its own. The outcome is that an intruder with the right password but the wrong machine meets a challenge the real owner clears and they cannot. ShieldLabs surfaces the device match and the named signals; your login code owns the step-up decision.
The country you compare comes from the public public_ip, which a VPN can fake to match the account’s home region. The webhook also carries local_ip — the real network IP and its country. When the two disagree, detection_flags.ip_mismatch is set to true. On a takeover-shaped login, treat that mismatch as corroborating evidence even when the surface country looks familiar.

Build it

1

Create a ShieldLabs account and get your keys

Sign up for free and get 5,000 identifications, or log in if you already have an account. Register the domain you want to identify visitors on, then open the Keys page. Use the Public Key to initialize the snippet in the browser, and keep your server-side credentials on your backend: the Private API Key authenticates the History API, and each webhook endpoint has its own whsec_… signing secret. See Keys.
2

Identify the login and read the score

Wire the snippet into your login step (checkAuthenticatedUser with the account’s hashed id, or checkAnonymous before the account is known), then on your server read the scored result with the shared waitForScore helper — your webhook cache, with a short timeout, falling back to a History API read by request_id.
3

Compare the login device against the account's history

Before issuing the session for an established account, pull the devices and countries that UserHID has used before and compare them to the one in front of you. shieldHistory is the shared History API read, here keyed by user_hid; it returns the data array (newest first), each row carrying device_id, country, and score. Reads on account.shieldlabs.ai are free, so this lookup costs nothing.
Compare the login device against the account's history
// Runs after your password check passes, before you issue the session.
async function takeoverRisk(userHid, shield) {
  const score    = shield?.score ?? 0;                  // 0–100, this login's anonymity
  const deviceId = shield?.device_id;
  const country  = shield?.public_ip?.country;         // public IP's country

  const rows = await shieldHistory('user_hid', userHid, 50); // your own recent sessions
  const knownDevices   = new Set(rows.map((r) => r.device_id).filter(Boolean));
  const knownCountries = new Set(rows.map((r) => r.country).filter(Boolean));

  // The all-zero DeviceID is "no device", not a new one. Do not treat it as
  // unseen, or every blocked or JS-disabled login looks like a fresh device.
  const NIL = '00000000-0000-0000-0000-000000000000';
  const usableDevice = deviceId && deviceId !== NIL;

  const newDevice  = usableDevice && !knownDevices.has(deviceId);
  const newCountry = country && knownCountries.size > 0 && !knownCountries.has(country);

  // A High-weight environment signal on an established account is itself
  // takeover-shaped: Anti-detect Browser (60), OS Mismatch (60),
  // JavaScript Disabled (90). Read them off the same cached payload.
  const strongSignal = (shield?.signals ?? []).some((s) => s.weight >= 60);

  return { score, newDevice, newCountry, strongSignal };
}
The account's recent devices and countries
curl "https://account.shieldlabs.ai/api/v1/history/user_hid/a1b2c3d4hasheduserid?limit=50" \
  -H "Authorization: Bearer sec_your_private_api_key"
A blocked or JavaScript-disabled browser returns the all-zero DeviceID (00000000-0000-0000-0000-000000000000) and a Risk Score of 90 or higher, since the characteristics needed to derive a stable id were never collected. Route that login to verification on the score alone, and skip the new-device comparison — the all-zero id is the absence of a device, not a new one.
4

Escalate the takeover-shaped login

Combine the facts: a new device plus a new country, or a new device on an anonymized session, escalates. Step up, do not hard-block — a real customer buys a new laptop, travels, or signs in over a corporate VPN, and a second factor keeps the genuine owner in while still stopping an intruder who only has the password.
api/login.js escalate a takeover-shaped login
app.post('/api/login', async (req, res) => {
  const { username, password, shieldRequestId } = req.body;

  // 1. Your normal credential check first.
  const user = await verifyPassword(username, password);
  if (!user) return res.status(401).json({ error: 'invalid_credentials' });

  // 2. Wait briefly for the score; the helper falls back to History by request_id.
  const shield = await waitForScore(shieldRequestId, 2000);
  if (!shield) {
    // Missing data is not "clean". Default an established account to a second factor.
    return res.status(200).json({ status: 'require_2fa', reason: 'verifying' });
  }

  // 3. The device-and-location comparison unique to takeover.
  const { score, newDevice, newCountry, strongSignal } =
    await takeoverRisk(user.hashedId, shield);

  // 4. Combine. Branch on the Score band and the boolean facts, never on a
  //    signal label string. A High-weight environment signal escalates alone.
  if (strongSignal || (newDevice && (newCountry || score >= 60))) {
    await alertAccountOwner(user.id, shield);          // notify the real owner
    return res.status(200).json({ status: 'verify', method: 'strong' });
  }
  if (newDevice || score >= 30) {
    return res.status(200).json({ status: 'require_2fa', method: 'otp' });
  }

  // Known device, clean session: issue the session, no extra friction.
  return issueSession(user, res);
});
A new DeviceID alone can be a real customer’s new phone, so reserve an outright block for an account already under an active attack, and tune the thresholds against your own login traffic. The step-up 2FA tutorial owns the band ladder; here the inputs change the rung.
5

Watch the takeover patterns and build a watchlist

The History read above reconstructs one account’s device history on demand. For the standing view across all accounts, the dashboard Patterns compute the same shapes historically and grade each flagged account Suspicious, then Dangerous, as evidence accumulates. Below the Suspicious threshold an account is the unflagged baseline, which is never recorded. Patterns are dashboard-only; they do not ride on the webhook.
An account appearing from a (device, country) combination it has never used before. The closest single pattern to a takeover, and the one to watch first: pull its flagged UserHIDs and feed them into a step-up watchlist.
One account reached from many distinct devices over the window — a spread that can mean a shared or resold account, and at the high end an account being worked from a string of new machines.
The same account appearing from several countries in a short span, the impossible-travel shape that VPN hopping and a hijacked session both produce.
An account whose DeviceID or VisitorID keeps changing, a hint of anti-detect tooling cycling its environment between attempts to look like a new visitor each time.
Export the flagged entities as CSV or JSON and use the Dangerous UserHIDs as a step-up watchlist at your login gate: any session for one of those accounts gets a second factor regardless of the per-login score.
Identity continuity rests on the DeviceID, not the VisitorID. The VisitorID is recomputed from the device and a browser cookie, so clearing cookies gives the same browser a fresh VisitorID. Compare the device a UserHID arrives on against the DeviceIDs it has used before, and treat a VisitorID change as a weaker hint, not the primary key.

Test it

To confirm device continuity holds, log into the same account from one browser, then clear cookies and log in again, then open an incognito or private window and log in once more. Each session resets the cookie_id and the visitor_id, but the server-derived device_id stays the same, so your newDevice check correctly reads all three as the known machine. Now log in from a second browser or a different device: that one returns a device_id your history has never seen for the UserHID, which is the takeover shape your gate escalates on. Rotating the IP through a VPN or proxy adds the matching anonymity signals to the score without changing the durable DeviceID. A guide, not a rule. Layer the conditions: a takeover login trips more than one, and friction should rise as they stack.
Condition for an established accountSuggested login action
Known DeviceID, clean session (Score under 30)Allow
New DeviceID, otherwise cleanRequire a second factor
New DeviceID and new CountryRequire strong verification, alert the owner
New DeviceID and High-band anonymity (datacenter, VPN, Tor)Require strong verification, alert the owner
All-zero DeviceID (blocked or JS-disabled, Score 90+)Route to verification on the score alone
UserHID on the “New Device and New Country” Dangerous watchlistStep up on every session until reviewed

Next

Step-up 2FA on Risky Logins

The threshold ladder this tutorial escalates into: when a risky login becomes a second-factor challenge.

Slow Down Credential Stuffing

The other login defense: throttle the flood of attempts on the durable DeviceID before takeover is even on the table.