> ## Documentation Index
> Fetch the complete documentation index at: https://docs.shieldlabs.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Account Takeover (ATO)

> Learn how to detect and prevent account takeover (ATO): catch a login from a new device or country on a known account and step up before the takeover.

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](/features/identification)** 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)](/features/risk-scoring) reads the session's anonymity on top, folding Datacenter IP, VPN, Proxy, Tor, Privacy Relay, anti-detect browser, and timezone-mismatch [signals](/features/anonymity-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](/features/patterns), the closest standing view of the takeover shape.

<Note>
  This tutorial is the device-and-location half of login security. The [step-up 2FA](/use-case/step-up-authentication) tutorial owns the threshold ladder that turns a risky login into a second-factor challenge, and the [credential stuffing](/use-case/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.
</Note>

## 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.

<Note>
  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.
</Note>

## Build it

<Steps>
  <Step title="Create a ShieldLabs account and get your keys">
    [Sign up for free](https://app.shieldlabs.ai/) 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](/setup/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](/api/server-api), and each webhook endpoint has its own `whsec_…` signing secret. See [Keys](/setup/keys).
  </Step>

  <Step title="Identify the login and read the score">
    Wire the [snippet](/setup/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](/use-case) — your webhook cache, with a short timeout, falling back to a [History API](/api/server-api) read by `request_id`.
  </Step>

  <Step title="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.

    ```js Compare the login device against the account's history theme={null}
    // 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 };
    }
    ```

    ```bash The account's recent devices and countries theme={null}
    curl "https://account.shieldlabs.ai/api/v1/history/user_hid/a1b2c3d4hasheduserid?limit=50" \
      -H "Authorization: Bearer sec_your_private_api_key"
    ```

    <Note>
      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.
    </Note>
  </Step>

  <Step title="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.

    ```js api/login.js escalate a takeover-shaped login theme={null}
    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);
    });
    ```

    <Warning>
      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](/use-case/step-up-authentication) tutorial owns the band ladder; here the inputs change the rung.
    </Warning>
  </Step>

  <Step title="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](/features/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.

    <AccordionGroup>
      <Accordion title="New Device and New Country" icon="location-dot">
        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.
      </Accordion>

      <Accordion title="Many Devices on One Account" icon="laptop">
        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.
      </Accordion>

      <Accordion title="Multiple Countries on One Account" icon="earth-americas">
        The same account appearing from several countries in a short span, the impossible-travel shape that VPN hopping and a hijacked session both produce.
      </Accordion>

      <Accordion title="Changing IDs on One Account" icon="arrows-spin">
        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.
      </Accordion>
    </AccordionGroup>

    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.
  </Step>
</Steps>

<Note>
  Identity continuity rests on the DeviceID, not the VisitorID. The [VisitorID](/features/identification) 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.
</Note>

## 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.

## Recommended starting policy

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 account                            | Suggested login action                       |
| --------------------------------------------------------------- | -------------------------------------------- |
| Known DeviceID, clean session (Score under 30)                  | Allow                                        |
| New DeviceID, otherwise clean                                   | Require a second factor                      |
| New DeviceID **and** new Country                                | Require 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 watchlist | Step up on every session until reviewed      |

## Next

<CardGroup cols={2}>
  <Card title="Step-up 2FA on Risky Logins" icon="lock" href="/use-case/step-up-authentication">
    The threshold ladder this tutorial escalates into: when a risky login becomes a second-factor challenge.
  </Card>

  <Card title="Slow Down Credential Stuffing" icon="user-lock" href="/use-case/credential-stuffing">
    The other login defense: throttle the flood of attempts on the durable DeviceID before takeover is even on the table.
  </Card>
</CardGroup>
