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

# Ban Evasion (Ban Enforcement)

> Learn how to detect and prevent ban evasion by banning every device a banned account used, so a cleared cookie or a fresh account cannot get them back in.

A banned user comes back two ways, and ShieldLabs covers both. They can mask the connection behind a VPN, proxy, or anti-detect browser, and the [Risk Score (0-100)](/features/risk-scoring) and [risk signals](/features/risk-signals) flag that masked return even on a new device. Or they can look new without hiding, by clearing cookies, opening an incognito window, or registering a fresh account. Each resets the **Visitor ID**, but the durable, server-derived **Device ID** does not move. Ban every device the banned account used, and read the Risk Score on whatever comes back.

## What is ban evasion?

Ban evasion is when a user who has been banned, suspended, or blocked returns to a service under a new identity: by clearing cookies, opening an incognito session, registering a fresh account, or masking the connection behind a VPN, proxy or anti-detect browser. The new session looks unrelated to the old one even though the same person, and often the same hardware, is behind it.

## How ShieldLabs surfaces it

ShieldLabs resolves each identification to a set of [identifiers](/features/identification), joined by the `request_id`: the **Visitor ID** (one device plus one cookie), which the evader resets by clearing cookies, and the durable **Device ID**, which holds. A cleared cookie creates a new Visitor ID, a VPN supplies a new public IP and an incognito window looks like a first-time visitor, so none of those is a banlist key. The Device ID is: it is computed on the server from stable device characteristics rather than read from a cookie, so the same browser returns the same Device ID after a cookie wipe, in incognito and through IP changes. Pair it with `local_ip.ip`, the address the browser itself reports, which can differ from `public_ip.ip` behind a VPN or proxy. When the two addresses differ, `detection_flags.ip_mismatch` is `true`; the flag 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.

The account is the unit you ban. Read the banned account's identifications by `user_hid` and every Device ID it has used goes on the banlist, not only the device of its last session; the other accounts seen on those devices are the next ones to review.

<Note>
  A different browser on the same machine gets its own Device ID, and a wiped or materially changed device can produce a new one, so treat device-level counts as estimates and pair the Device ID with the local IP.
</Note>

## Stop a banned user from coming back

The play is one banlist lookup at the start of every session, keyed on what the returning user cannot easily change. At ban time, record every Device ID the banned account has used and every local IP you recorded for it; on each session read the incoming Device ID and `local_ip.ip`, plus the [`detection_flags`](/glossary#detection-flags) for a quick masked-return branch. The banlist policy: if the incoming Device ID is on your device-level banlist, block it; if only the local IP matches, review it (a shared router or office NAT can be innocent); if neither matches but the session is masked (an anti-detect browser, browser automation, or a local IP country that differs from the public one), review it. The outcome: a banned user who clears cookies, opens an incognito window, or rotates a VPN still lands on the same Device ID and gets stopped at the door, while honest visitors on shared networks only get a softer review.

## 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 session before you trust the cookie">
    Load the [snippet](/setup/snippet) and call `forceCheckAnonymous` at the start of every session, so the Device ID and local IP are available before you read the cookie or even know which account this is. A plain `checkAnonymous` would skip a repeat check in the same visit within five minutes; each forced call counts as one identification. Once the user signs in, pass the hashed User HID with `checkAuthenticatedUser` on the signed-in pages: the ban step below reads the account's devices by it.

    ShieldLabs POSTs one webhook per identification; verify `X-Shield-Signature` on the raw body, then cache the result keyed by `request_id`. That handler is the shared `scoreCache` / `waitForScore` helper defined once in the [Use Case Tutorials](/use-case#the-shared-helpers). It returns the webhook `data` object with `local_ip` and `detection_flags`; when it falls back to the History API there is no `local_ip`, so treat a missing value as unknown, not clean. History has no Local IP search either, so record the local IPs of each signed-in account from its webhooks as they arrive; the ban step below reads them from your store.
  </Step>

  <Step title="Record device keys at ban time">
    When you ban a user, persist what travels with the account. The cookie and the account both reset on demand; the Device IDs the account has used and the local IPs you recorded for it are what a returning user has to keep using.

    ```js api/ban-user.js theme={null}
    // Call it from your webhook handler for each identification.scored webhook,
    // after the signature check. History has no Local IP search, so keep each
    // signed-in account's local IPs in your own store as the webhooks arrive.
    async function recordLocalIp(data) {
      if (data.user_hid && data.user_hid !== 'anonymous' && data.local_ip?.ip) {
        await localIpsByAccount.add(data.user_hid, data.local_ip.ip);
      }
    }

    // At ban time, ban what travels with the account, not only its last session.
    async function banUser(accountId, userHid, reason) {
      await markAccountBanned(accountId, reason);

      // Every device this account has used: the shared accountView helper reads the
      // account's identifications by user_hid (newest 100; page with offset for
      // long-lived accounts). The all-zero Device ID is never counted as a device.
      const account = await accountView(userHid);
      for (const deviceId of account.devices) {
        await banlist.addDevice(deviceId, { accountId, reason });
      }

      // Every local IP you recorded for the account, as a soft key. It is the
      // address the browser itself reports, which often stays the same when a VPN
      // or proxy rotates the public IP.
      for (const localIp of await localIpsByAccount.get(userHid)) {
        await banlist.addLocalIp(localIp, { accountId, reason });
      }
    }
    ```

    Other accounts already seen on those devices are the next ones to review: the shared `accountsBehindDevice` helper counts them from the History API by `device_id`.

    <Frame caption="The devices linked to one user in the analytics dashboard, with the band of the identifications they share.">
      <img className="block dark:hidden" src="https://mintcdn.com/shieldlabs-725d18f1/JyleDzUFYU3SXP4Q/images/dashboard/user-card-details-linked.png?fit=max&auto=format&n=JyleDzUFYU3SXP4Q&q=85&s=15a35b82378c6d3a007dd3394904563d" alt="The Details section of the user card for User HID a91f3c7e5b2d4086 in the analytics dashboard with Linked devices open: 2 devices, one Trusted with 7 identifications and one Dangerous with 5, and the Linked local IPs counter showing 2." width="2238" height="712" data-path="images/dashboard/user-card-details-linked.png" />

      <img className="hidden dark:block" src="https://mintcdn.com/shieldlabs-725d18f1/JyleDzUFYU3SXP4Q/images/dashboard/user-card-details-linked-dark.png?fit=max&auto=format&n=JyleDzUFYU3SXP4Q&q=85&s=74cb3f5cf0a3421e0c7e3fd2c915b467" alt="The Details section of the user card for User HID a91f3c7e5b2d4086 in the analytics dashboard in the dark theme with Linked devices open: 2 devices, one Trusted with 7 identifications and one Dangerous with 5, and the Linked local IPs counter showing 2." width="2238" height="712" data-path="images/dashboard/user-card-details-linked-dark.png" />
    </Frame>
  </Step>

  <Step title="Check the banlist before the cookie">
    On every session, look up the incoming Device ID first. A banned Device ID arriving under a fresh Visitor ID is the tell that someone cleared storage to get back in. Block on a device match; only review on a local-IP match, since a shared office router or home NAT can be innocent.

    ```js api/session-open.js theme={null}
    app.post('/api/session-open', async (req, res) => {
      const { shieldlabsRequestId } = req.body;

      // Session start is a guest flow: there is no User HID to match yet.
      const risk = await waitForScore(shieldlabsRequestId, 2000);
      if (!risk || risk.risk_score > 100 || risk.device_id === NIL_DEVICE) {
        // No identification, the 999 rate-limit marker, or no usable device
        // signals: route to review, never auto-ban and never auto-allow.
        return res.json({ action: 'review', reason: 'device_unknown' });
      }
      const deviceId = risk.device_id;
      const localIp  = risk.local_ip?.ip; // the address the browser reports; often stable when the public IP rotates
      const flags    = risk.detection_flags ?? {};

      // The core check: is this device on the banlist, whatever the cookie says?
      if (await banlist.hasDevice(deviceId)) {
        return res.json({ action: 'block', reason: 'banned_device_returned' });
      }

      // Defense in depth: same local IP as a banned session, on a new device.
      // Weaker on its own (a shared router, an office NAT), so review, do not block.
      if (localIp && (await banlist.hasLocalIp(localIp))) {
        return res.json({ action: 'review', reason: 'banned_local_ip' });
      }

      // A masked return on a new device. detection_flags holds the booleans,
      // including informational ones such as ip_mismatch, so compare the two IP
      // countries rather than branching on that flag alone.
      const countryDiffers =
        risk.local_ip?.country && risk.public_ip?.country &&
        risk.local_ip.country !== risk.public_ip.country;
      if (flags.anti_detect_browser || flags.browser_automation || countryDiffers) {
        return res.json({ action: 'review', reason: 'masked_return' });
      }

      return res.json({ action: 'allow' });
    });
    ```
  </Step>

  <Step title="Route the all-zero Device ID to review">
    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. **Never auto-ban it**: many unrelated identifications share it, so a ban would collapse many distinct visitors onto one key and lock out a whole class of clients. Send it to manual review, weighed with the local IP and your own context, and treat a session that arrives with no identification at all the same way. (An anti-detect browser that still runs is different: it produces a real, non-zero Device ID and carries its own anti-detect weight on the score.)
  </Step>

  <Step title="Feed High-Risk Events into your banlist and tune">
    The per-session lookup stops a known device at the door; [High-Risk Events](/features/high-risk-events) show the evasion shape across accounts. When a **Multi-accounting** event arrives for a user through the API or webhooks, or when you review it in the analytics dashboard, add the devices and local IPs linked to that user to your banlist as a watchlist (see below). An **Account takeover** event means the account's owner is the one at risk: step up that account's next sign-in, and add to the banlist only the device you confirm as someone else's after review, never every device linked to the user. Start in logging-only mode before you turn on hard blocks.
  </Step>
</Steps>

## Test it

Confirm the key holds before you trust it. Identify a session in your browser and note the Device ID, then clear cookies (or open an incognito window) and identify again: the Visitor ID changes but the same Device ID comes back. Repeat from behind a VPN to see the connection signals fire on the score while the Device ID stays put. A second, different browser on the same machine gets its own Device ID, which is why you pair it with the local IP and ban every device the account used.

## Spot the evasion shape with High-Risk Events

ShieldLabs detects the account-level evasion shapes on your users as [High-Risk Events](/features/high-risk-events), each at **Medium** or **High** confidence, and they are available in the analytics dashboard, the API and webhooks. Events are keyed on the User HID, so pass the hashed account id with `checkAuthenticatedUser`. Two map to ban evasion:

<AccordionGroup>
  <Accordion title="Multi-accounting" icon="mobile-screen">
    Several accounts run by one person, linked through the devices and network they share: the shape of a banned user who keeps registering fresh accounts from the same device or network.
  </Accordion>

  <Accordion title="Account takeover" icon="globe">
    An existing account appearing in a new environment that points to someone else using it: a banned user coming back through an account that is not theirs.
  </Accordion>
</AccordionGroup>

An environment cycled between sessions to look new each time shows up on the score through the risk signals, including anti-detect browser detection. Add the devices linked to users with a Multi-accounting event to your banlist as a watchlist; for Account takeover, ban only the devices you confirm as someone else's. 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) walks through the review.

### Reconstruct a device's history programmatically

You can also check what a device has done from the [History API](/api/server-api). Read by `device_id` to see every identification and account that device has touched, newest first.

<Frame caption="One Device ID in the analytics dashboard, with every account linked to it.">
  <img className="block dark:hidden" src="https://mintcdn.com/shieldlabs-725d18f1/JyleDzUFYU3SXP4Q/images/dashboard/device-card-linked-accounts.png?fit=max&auto=format&n=JyleDzUFYU3SXP4Q&q=85&s=83e8b1b0c4f63a38e35219aecc83472e" alt="The device card for Device ID d290f1ee-6c54-4b01-90e6-d701748f0851 in the analytics dashboard: band Dangerous, 14 identifications, and Linked accounts open with 6 accounts: 3 Dangerous, 1 Suspicious and 2 Trusted." width="2254" height="1436" data-path="images/dashboard/device-card-linked-accounts.png" />

  <img className="hidden dark:block" src="https://mintcdn.com/shieldlabs-725d18f1/JyleDzUFYU3SXP4Q/images/dashboard/device-card-linked-accounts-dark.png?fit=max&auto=format&n=JyleDzUFYU3SXP4Q&q=85&s=55f17c303f9c27d58183734d642466c3" alt="The device card for Device ID d290f1ee-6c54-4b01-90e6-d701748f0851 in the analytics dashboard in the dark theme: band Dangerous, 14 identifications, and Linked accounts open with 6 accounts: 3 Dangerous, 1 Suspicious and 2 Trusted." width="2254" height="1436" data-path="images/dashboard/device-card-linked-accounts-dark.png" />
</Frame>

```bash Read one device's history theme={null}
curl "https://account.shieldlabs.ai/api/v1/history/device_id/5eb7fd5c-1c5e-4a9f-9b21-7d2e8c0a1234?limit=100" \
  -H "Authorization: Bearer sec_your_private_api_key"
```

```js Confirm a banned device's return theme={null}
// A banned device coming back under brand new accounts confirms the evasion.
if (await banlist.hasDevice(deviceId)) {
  const accounts = await accountsBehindDevice(deviceId); // "anonymous" is not counted
  flagForReview(deviceId, { accounts, note: 'banned device active under new accounts' });
}
```

<Note>
  History reads on `account.shieldlabs.ai` and webhook delivery are free. Lean on the per-session banlist lookup and High-Risk Events for routine enforcement.
</Note>

## Recommended starting policy

A guide, not a rule. The local IP is a soft key on purpose, since legitimate visitors share networks.

| Condition | Suggested action |
| - | - |
| Incoming Device ID on your banlist | Block, the banned device has returned |
| New device, but local IP matches a banned session | Review, could be a shared network |
| No identification, or an all-zero Device ID | Manual review, never auto-ban |
| Device linked to a user with a **Multi-accounting** event (Medium or High confidence) | Add the device to your banlist, then review |
| Trusted Risk Score, no banlist match | Allow |

<Card title="Next: New Account Fraud" icon="arrow-right" href="/use-case/new-account-fraud">
  The companion guide for the registration step: catch the fresh accounts a banned user opens before they get created.
</Card>


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