Skip to main content
A banned user comes back two ways, and ShieldLabs covers both. They can mask the session behind a VPN, proxy, or anti-detect browser — the Risk Score (0–100) and anonymity 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 VisitorID, but the durable, server-derived DeviceID does not move. Ban the device, and read the 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 visit to a set of identifiers joined by the RequestID: the cookie-scoped VisitorID the evader resets at will, and the durable, server-derived DeviceID that survives the reset. A cleared cookie mints a fresh VisitorID, a VPN supplies a fresh public IP, and an incognito window looks like a first-time visitor — so none of those is a banlist key. The DeviceID is. It is computed on the server from stable device characteristics rather than read from a cookie, so the same browser hands back the same DeviceID after a cookie wipe, in incognito, and across an IP rotation. Pair it with the real network IP from local_ip.ip (where public_ip.ip is the public IP a VPN rotates freely); when the two disagree detection_flags.ip_mismatch is set to true.
The DeviceID is browser-bound. A different browser on the same machine, or a wiped or materially changed device, can produce a new DeviceID, so treat device-level counts as estimates and pair the DeviceID with the local IP.

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. Record the DeviceID and the real network IP at ban time, then on each visit read the incoming DeviceID and local_ip.ip plus the detection_flags for a quick masked-return branch. The rule your code applies: if the incoming DeviceID 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 visit is masked (ip_mismatch or anti_detect_browser), review it. The outcome: a banned user who clears cookies, opens an incognito window, or rotates a VPN still lands on the same DeviceID and gets stopped at the door, while honest visitors on shared networks only get a softer review.

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 session before you trust the cookie

Load the snippet and run an identification at the start of every visit, so the DeviceID and local IP are available before you read the cookie or even know which account this is. ShieldLabs POSTs one signed webhook per scored 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, which returns the raw webhook payload — so local_ip and detection_flags are already available to the checks below, no extra mapping needed.
3

Record device keys at ban time

When you ban a user, persist what travels with them. The cookie and the account both reset on demand; the DeviceID and the local IP are what a returning user has to keep using.
api/ban-user.js
// At ban time you already have the offender's last scored session.
// Persist the durable identifiers, not just the account id.
async function banUser(accountId, shieldRequestId, reason) {
  const shield = await waitForScore(shieldRequestId, 2000);

  await markAccountBanned(accountId, reason);

  // Record the device-level keys so a cleared cookie or a fresh account
  // does not buy a clean slate. A blocked / all-zero DeviceID is not a key.
  const ZERO_DEVICE = '00000000-0000-0000-0000-000000000000';
  if (shield?.device_id && shield.device_id !== ZERO_DEVICE) {
    await banlist.addDevice(shield.device_id, { accountId, reason });
  }
  // Key the soft ban on the real network IP (local_ip.ip), NOT the
  // public IP, since a VPN or proxy hands the evader a fresh public IP.
  const localIp = shield?.local_ip?.ip;
  if (localIp) {
    await banlist.addLocalIp(localIp, { accountId, reason });
  }
}
4

Check the banlist before the cookie

On every visit, look up the incoming DeviceID first. A banned DeviceID arriving under a fresh VisitorID 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.
api/session-open.js
app.post('/api/session-open', async (req, res) => {
  const { shieldRequestId } = req.body;

  const shield   = await waitForScore(shieldRequestId, 2000);
  const deviceId = shield?.device_id;
  const localIp  = shield?.local_ip?.ip; // real network IP, durable across a VPN switch
  const flags    = shield?.detection_flags ?? {};

  // All-zero DeviceID = "device unknown" (see below): route to review, never auto-ban.
  const ZERO_DEVICE = '00000000-0000-0000-0000-000000000000';
  if (!deviceId || deviceId === ZERO_DEVICE) {
    return res.json({ action: 'review', reason: 'device_unknown' });
  }

  // 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' });
  }

  // Quick branch on the denormalized flags: a masked return on a new device.
  // detection_flags mirrors the explainable signals array, no parsing needed.
  if (flags.ip_mismatch || flags.anti_detect_browser) {
    return res.json({ action: 'review', reason: 'masked_return' });
  }

  return res.json({ action: 'allow' });
});
5

Route the all-zero DeviceID to review

A session that runs with JavaScript disabled or blocks the snippet cannot produce stable device characteristics, so the server returns the all-zero DeviceID (00000000-0000-0000-0000-000000000000) and scores the visit at least 90. That high score reflects a non-cooperating client, not a confirmed ban. Never auto-ban it — every blocked or JS-disabled visitor shares 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. (An anti-detect browser that still runs is different: it usually produces a real, non-zero DeviceID and carries its own anti-detect weight on the score.)
6

Feed the patterns into your banlist and tune

The per-visit lookup stops a known device at the door; the dashboard Patterns show the evasion shape over time. Export the Dangerous DeviceIDs and local IPs and feed them into your banlist as a watchlist (see below), and start in logging-only mode before you turn on hard blocks.

Test it

Confirm the key holds before you trust it. Identify a session in your browser and note the DeviceID, then clear cookies (or open an incognito window) and identify again — the VisitorID changes but the same DeviceID comes back. Repeat from behind a VPN to see the connection signals fire on the score while the DeviceID stays put. A second, different browser on the same machine can produce a new DeviceID, which is why you pair it with the local IP and treat device-level counts as estimates.

Spot the evasion pattern in the dashboard

The dashboard Patterns grade each flagged entity Suspicious or Dangerous as the count crosses a threshold in a rolling window. An entity below the first threshold is the unflagged baseline, never recorded as a grade.
One account whose VisitorID or DeviceID keeps changing, a hint of cleared storage, anti-detect tooling, or an environment cycled between visits to look new each time.
One device tied to many accounts, the shape of a banned user who keeps registering fresh accounts from the same machine to get back in.
Multiple accounts sharing one local IP across different devices and public IPs, a sign of coordinated returns or a ban dodged from behind shared infrastructure.
Many accounts behind a single local network, the shape of an account farm or a banned user spinning up registrations from one place.
An existing account reappears from a new device-and-country pair, the classic post-ban re-entry where someone comes back on fresh hardware from a new location.
Each pattern grades over a rolling window: the device and local-IP patterns over 30 days, “Changing IDs on One Account” over 7 days, and “New Device and New Country” by comparing the recent 7 days against the account’s history. Levels never downgrade: once an entity is flagged Dangerous, new clean activity does not clear it. Export the Dangerous DeviceIDs and local IPs (CSV or JSON) and feed them straight into your banlist as a watchlist.

Reconstruct a device’s history programmatically

You can also check what a device has done from the History API. Read by device_id to see every session and account that machine has touched, newest first.
Read one device's history
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"
Confirm a banned device's return
async function deviceHistory(deviceId) {
  const rows = await shieldHistory('device_id', deviceId, 100);
  const accounts = new Set(rows.map((r) => r.user_hid).filter(Boolean));
  return { sessions: rows.length, accounts: accounts.size };
}

// A banned device coming back under brand new accounts confirms the evasion.
const { accounts } = await deviceHistory(deviceId);
if (await banlist.hasDevice(deviceId)) {
  flagForReview(deviceId, { accounts, note: 'banned device active under new accounts' });
}
Account History reads on account.shieldlabs.ai and webhook delivery are free; the Management History path bills 1 request per returned row (an empty result still bills 1). Lean on the per-visit banlist lookup and the pre-computed pattern export for routine enforcement, and reserve any billed Management reads for the cases you are actively confirming.
A guide, not a rule. The local IP is a soft key on purpose, since legitimate visitors share networks.
ConditionSuggested action
Incoming DeviceID on your banlistBlock, the banned device has returned
New device, but local IP matches a banned sessionReview, could be a shared network
All-zero DeviceID (blocked or JS-disabled, score 90+)Manual review, never auto-ban
”Changing IDs on One Account” or “Many Accounts on One Device” (Dangerous)Add the entity to your banlist, then review
Clean device, no banlist matchAllow

Next: Stop Multi-Accounting at Signup

The companion pattern for the registration step: catch the fresh accounts a banned user opens before they get created.