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

> Learn how to detect and prevent account takeover by checking each login against the devices and countries the account has used before.

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 User HID you have seen many times before, arriving on a **Device ID you have never seen for it**, often from a new country or behind a datacenter, VPN, or Tor connection. ShieldLabs gives you the account's history to compare against, the durable Device ID to recognize the device, the Risk Score to read the login's risk signals, and the **Account takeover** High-Risk Event, so your login flow 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 someone 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 account's history. ShieldLabs returns a durable **[Device ID](/features/identification)** for the device in front of you, derived on the server from stable device characteristics. It stays the same when the visitor clears cookies, opens an incognito window or rotates IPs, and an intruder on another machine arrives with a different Device ID. A User HID that has only ever appeared on one or two Device IDs, now signing in from a third, is the core takeover shape.

The [Risk Score (0-100)](/features/risk-scoring) of the login reads its [risk signals](/features/risk-signals) on top: Datacenter IP, VPN, Proxy, Tor, Privacy Relay, Anti-detect Browser, Browser Automation and Timezone Mismatch fold into one number. When an intruder fakes a familiar `public_ip.country` over a VPN, `local_ip`, the address the browser itself reports, can show a different country. Across the account's activity, ShieldLabs detects **Account takeover** as a [High-Risk Event](/features/high-risk-events#account-takeover) on the user, at **Medium** or **High** confidence. It is available in the analytics dashboard, the API and webhooks, is a separate axis from the Risk Score, and is keyed on the User HID you pass with `checkAuthenticatedUser`.

<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 Device ID. 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 **Device ID** and **Risk Score**, compare the device and country against the ones this User HID has used before, and check the account's worst band over its recent identifications. The login policy: a new device **and** a new country, or a new device **and** datacenter or Tor signals on the login, or a new device on an account with a Dangerous history, 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 device meets a challenge the real owner clears and they cannot. ShieldLabs detects the device change, the named signals, and the Account takeover event; you choose the step-up action for each case in your login flow.

<Note>
  The webhook carries two IP objects. `public_ip` is the public address and its country, which a VPN or proxy can set to match the account's home region. `local_ip` is the Local IP: the address the browser itself reports, which can differ from the public IP behind a VPN or proxy and can expose the network behind the mask. `local_ip.ip` is empty when the Local IP was not captured. `detection_flags.ip_mismatch` is `true` whenever the two are different addresses. It is informational, adds nothing to the Risk Score and can be ordinary on mobile networks, so compare `local_ip.country` with `public_ip.country` rather than branching on the flag alone. On a takeover-shaped login, a different country behind a familiar public one is supporting evidence.
</Note>

## Build it

<Steps>
  <Step title="Create a ShieldLabs account and get your keys">
    [Start Free](https://app.shieldlabs.ai/) with 5,000 identifications, one time, no credit card, or log in. In the analytics dashboard, add the domain you want to protect under **Integration > Domains**, then open **Integration > API keys** and copy its keys with the copy button next to each. The **Public Key** loads the snippet in the browser. Keep the server credentials on your backend: the **Private API Key** reads the [History API](/api/server-api), and each webhook endpoint has its own `whsec_…` signing secret. See [API keys](/setup/keys) and [Integration](/dashboard/integration).
  </Step>

  <Step title="Identify the login and read the Risk Score">
    Wire the [snippet](/setup/snippet) into your login step and call `forceCheckAnonymous` for every attempt, when the user starts filling the login form (its first focus). A plain `check*` call would be skipped if the browser was checked in the same visit within the last five minutes, and the login would reach your backend without a request ID. `onInitialized` fires when the check starts, before the snippet has sent the identification, so do not navigate away inside it; the [Login and 2FA](/use-case/step-up-authentication) tutorial shows the pattern. Pass the hashed User HID only after the password check succeeds, so the account's history holds only its own signed-in activity: an attempt identified with the User HID before the password is checked would add the device of whoever typed the username to that account. Call `forceCheckAuthenticatedUser` on the first signed-in page, then `checkAuthenticatedUser` on every signed-in page. On your server, read the scored result with the shared [`waitForScore` helper](/use-case#the-shared-helpers): 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, read the devices, countries and worst band of the account from its earlier identifications and compare them to the login in front of you. `accountView` is the shared helper from the [Use Case Tutorials](/use-case#the-shared-helpers): it reads the account's identifications by `user_hid` (newest 100), which hold only its signed-in activity, leaves out the 999 rate-limit marker, and returns the account's devices, countries and worst band. 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, risk) {
      const score    = risk.risk_score;           // 0-100, this login's Risk Score
      const deviceId = risk.device_id;
      const country  = risk.public_ip?.country;   // the public IP's country

      // The account's signed-in history. The login attempt is identified with
      // forceCheckAnonymous, so it is not part of it. Throws when the History read fails.
      const account = await accountView(userHid);

      // The all-zero Device ID is "no device", not a new one. Do not treat it as unseen.
      const usableDevice = deviceId && deviceId !== NIL_DEVICE;

      // Only an account with history can have a "new" device or country.
      const newDevice  = usableDevice && account.devices.size > 0 && !account.devices.has(deviceId);
      const newCountry = Boolean(country) && account.countries.size > 0 && !account.countries.has(country);

      // The account's worst band over its recent identifications.
      const accountWasDangerous = account.worstBand === 'Dangerous';

      // A heavy signal on an established account is itself takeover-shaped:
      // Tor (99), JavaScript Disabled (90), Browser Automation (60),
      // Anti-detect Browser (60), OS Mismatch (60). The flags are on the webhook
      // and on the History fallback; signals[] is on the webhook only.
      const flags = risk.detection_flags ?? {};
      const strongSignal =
        flags.tor || flags.javascript_disabled || flags.browser_automation ||
        flags.anti_detect_browser || flags.os_mismatch ||
        (risk.signals ?? []).some((s) => s.weight >= 60);

      return { score, newDevice, newCountry, accountWasDangerous, strongSignal };
    }
    ```

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

    <Note>
      An all-zero Device ID (`00000000-0000-0000-0000-000000000000`) means no usable device signals reached ShieldLabs for that identification; the rate-limit marker (Risk Score 999) is one such case. Route it to review rather than allowing it: send that login to verification and skip the new-device comparison, since 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, a new device on a masked login, or a new device on an account with a Dangerous history escalates. Step up rather than 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 it stops 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, shieldlabsRequestId } = 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. The guard. Missing data is not "clean": default an established account
      //    to a second factor. Every login attempt is identified with
      //    forceCheckAnonymous, so the identification carries "anonymous".
      const risk = await waitForScore(shieldlabsRequestId, 2000);
      if (!risk) {
        return res.status(200).json({ status: 'require_2fa', reason: 'no_identification' });
      }
      if (risk.user_hid !== 'anonymous') {
        return res.status(200).json({ status: 'require_2fa', reason: 'identification_mismatch' });
      }
      if (risk.risk_score > 100 || risk.device_id === NIL_DEVICE) {
        // The 999 rate-limit marker, or no usable device signals.
        return res.status(200).json({ status: 'require_2fa', reason: 'unverified_device' });
      }

      // 3. The device-and-location comparison unique to takeover.
      //    A failed History read is unverified: default to a second factor.
      const facts = await takeoverRisk(user.hashedId, risk).catch(() => null);
      if (!facts) {
        return res.status(200).json({ status: 'require_2fa', reason: 'history_unavailable' });
      }
      const { score, newDevice, newCountry, accountWasDangerous, strongSignal } = facts;

      // 4. Accounts with an Account takeover event, recorded when it arrives through
      //    the API or webhooks or when you review it in the analytics dashboard
      //    (your own store).
      const watched = await takeoverWatchlist.has(user.hashedId);

      // 5. Combine. Branch on the band and the boolean facts; signals[].name slugs
      //    are stable if one signal matters on its own. A heavy signal escalates alone.
      if (strongSignal || (newDevice && (newCountry || accountWasDangerous || score >= 60))) {
        await alertAccountOwner(user.id, risk);          // notify the real owner
        return res.status(200).json({ status: 'verify', method: 'strong' });
      }
      if (watched || newDevice || score >= 30) {
        return res.status(200).json({ status: 'require_2fa', method: 'otp' });
      }

      // Known device, clean login: issue the session, no extra friction.
      return issueSession(user, res);
    });
    ```

    <Warning>
      A new Device ID alone can be a real customer's new phone, so reserve an outright block for an account already under an active attack, and tune your cutoffs 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="Act on the takeover events">
    The History read above reconstructs one account's devices on demand. For the standing view across all your users, ShieldLabs detects the account-level shapes as [High-Risk Events](/features/high-risk-events), each at **Medium** or **High** confidence. They are available in the analytics dashboard, the API and webhooks. The Risk Score and risk signals of the login remain the input at the login itself.

    <AccordionGroup>
      <Accordion title="Account sharing" icon="laptop">
        One account used from several distinct devices: a spread that can mean a shared or resold account, and at the high end an account being worked from a string of new devices.
      </Accordion>

      <Accordion title="Impossible travel" icon="earth-americas">
        The same account appearing in locations it could not reach in the time between them, the shape a hijacked session produces.
      </Accordion>

      <Accordion title="Account takeover" icon="location-dot">
        An existing account appearing in a new environment that points to someone else using it. The event closest to this page, and the one to watch first.
      </Accordion>
    </AccordionGroup>

    When an Account takeover event arrives for a user through the API or webhooks, or when you review it in the analytics dashboard, act on the account: add the User HID, with the devices and local IPs linked to it, to a watchlist in your datastore, and at the login check the incoming `user_hid` and `device_id` against it. You choose the action for each case. The History API returns every device an account has used, by `user_hid`. The [acting guide](/guides/acting-on-risk-score#acting-on-high-risk-events) gives a starting point for each event.

    <Frame caption="An account with an Account takeover event in the analytics dashboard.">
      <img className="block dark:hidden" src="https://mintcdn.com/shieldlabs-725d18f1/JyleDzUFYU3SXP4Q/images/dashboard/event-account-takeover.png?fit=max&auto=format&n=JyleDzUFYU3SXP4Q&q=85&s=b04b1e49c0ed515ba9a26d245943ba8f" alt="The user card header for User HID 5e0c2b7d91a4f3c6 in the analytics dashboard: the Dangerous band pill, a red Account takeover pill (High confidence), 9 identifications (7 Trusted, 1 Suspicious, 1 Dangerous) and Details with 2 linked devices and 2 linked countries." width="2254" height="770" data-path="images/dashboard/event-account-takeover.png" />

      <img className="hidden dark:block" src="https://mintcdn.com/shieldlabs-725d18f1/JyleDzUFYU3SXP4Q/images/dashboard/event-account-takeover-dark.png?fit=max&auto=format&n=JyleDzUFYU3SXP4Q&q=85&s=9e36cdcc0f688a727b2d7833db623bb1" alt="The user card header for User HID 5e0c2b7d91a4f3c6 in the analytics dashboard in the dark theme: the Dangerous band pill, a red Account takeover pill (High confidence), 9 identifications (7 Trusted, 1 Suspicious, 1 Dangerous) and Details with 2 linked devices and 2 linked countries." width="2254" height="770" data-path="images/dashboard/event-account-takeover-dark.png" />
    </Frame>

    An account whose environment keeps changing between attempts shows up on each login's score through the risk signals, including anti-detect browser detection. Use the User HIDs on your Account takeover watchlist at your login gate: any login for one of those accounts gets a second factor regardless of the per-login score, as the `watched` branch above shows.
  </Step>
</Steps>

<Note>
  Identity continuity rests on the Device ID, not the Visitor ID. The [Visitor ID](/features/identification) is one device plus one browser cookie, so clearing cookies gives the same browser a fresh Visitor ID. Compare the device a User HID arrives on against the Device IDs it has used before, and treat a Visitor ID 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 login 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 device. Now log in from a second browser or a different device: that one returns a `device_id` your history has never seen for the User HID, which is the takeover shape your gate escalates on. Rotating the IP through a VPN or proxy adds the matching risk signals to the score without changing the Device ID.

## 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 Device ID, Trusted login (Risk Score 0-29) | Allow |
| New Device ID, otherwise clean | Require a second factor |
| New Device ID **and** new country | Require strong verification, alert the owner |
| New Device ID **and** a Dangerous Risk Score (60-100) with datacenter, VPN, or Tor signals | Require strong verification, alert the owner |
| New Device ID on an account whose worst band is Dangerous | Require strong verification, alert the owner |
| No identification for the login, or an all-zero Device ID | Require a second factor; skip the new-device comparison |
| User HID with an **Account takeover** event (Medium or High confidence) | Step up on every login 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 Device ID before takeover is even on the table.
  </Card>
</CardGroup>


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.