Skip to main content
Multi-accounting is one person wearing many faces: a string of distinct accounts — different emails, different cookies, often different public IPs — that all trace back to one durable DeviceID and frequently one local network. It is the umbrella behind bonus, free-trial, and loyalty abuse. ShieldLabs gives you that durable id plus the per-session Risk Score, so your own code can link the “different” customers back to the same person and decide what to do.

What is multi-accounting?

Multi-accounting is the practice of one individual creating and operating several accounts on a service that intends one account per person, usually to claim a per-customer reward more than once, evade a limit, or coordinate activity that should come from separate users. The accounts look independent on the surface but share underlying hardware or network signals.

How ShieldLabs surfaces it

ShieldLabs derives a durable DeviceID for every session — server-derived from the browser environment rather than stored, so it cannot be reset by clearing cookies, opening incognito, or rotating IPs. Every naive identifier a person can reset, they do reset: a cleared cookie mints a fresh cookie_id and visitor_id, a VPN or proxy hands them a new public IP, an incognito window looks like a first-time visitor. Counting on any of those just counts the disguises; the DeviceID holds steady underneath, so the account-linking below rests on it. Multi-accounting works across two timescales, so it uses both halves of the product:
  • Per session, the Risk Score (0–100) and its signals tell you whether this one session is masked. The tells that ride along with farming are the anonymity and consistency signals — VPN, Proxy, Tor, Privacy Relay, Browser VPN/Proxy, Datacenter IP, Abuser Flag, plus OS Mismatch, Timezone Mismatch, and environment tells like Anti-detect Browser and JavaScript Disabled. Each can be innocent in isolation, so treat them as weight, not proof.
  • Across sessions, the dashboard Patterns count the relationships the score cannot see in a single request: Many Accounts on One Device (the classic shape, keyed on the durable DeviceID), Many Accounts on One Local IP (the farm behind one router or NAT, even when each session uses a fresh cookie and a different public IP), and Changing IDs on One Account (a person cycling devices and visitor IDs to look fresh).
Patterns are a dashboard-only feature, graded Suspicious then Dangerous over a rolling window. They are not part of the webhook payload or any API field; you read them on the dashboard Patterns tab or its CSV/JSON export. An entity below the Suspicious threshold is the unflagged baseline and is never recorded.

Prevent multi-accounting

Read two things on the action that matters (signup, the reward or trial claim, a withdrawal): the durable DeviceID, and the count of distinct user_hid values already seen on it. The rule your code applies is a threshold on that count — when one device carries more accounts than your policy allows, hold the action for verification, and escalate further when the session is also masked. The outcome is that the “different” customers a person spins up collapse back to the one machine behind them. ShieldLabs surfaces the durable id and the per-session Risk Score; your code owns the count, the threshold, and the verdict.

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

Add the snippet to the authenticated actions where multi-accounting pays off: account creation, the reward or trial claim, a withdrawal, a vote. Re-identify on the action so you score the live session, and pass the account’s hashed id, never a raw email.
account-action.html
<script type="module">
  const mod = await import(
    'https://cdn.shieldlabs.ai/snippet.js?publicKey=YOUR_PUBLIC_KEY'
  );
  // Pass the hashed account id, never a raw email or user id.
  // The callback hands you the requestID to correlate with the score.
  mod.checkAuthenticatedUser('8a9f-hashed-account-id', (ip, requestID) => {
    document.getElementById('shield-request-id').value = requestID;
  });
</script>

<form method="POST" action="/api/account-action">
  <input type="hidden" id="shield-request-id" name="shieldRequestId" />
  <button type="submit">Continue</button>
</form>
3

Read the scored webhook on your server

ShieldLabs scores the identification and posts the result to your endpoint — the canonical fields are request_id, device_id, visitor_id, user_hid, public_ip, local_ip, score, signals, and detection_flags (full schema in the webhook reference). Verify X-Shield-Signature on the raw body, respond fast, and cache the result by request_id with the shared waitForScore helper. If a webhook is ever missed, that helper falls back to a History API read by request_id.
A multi-accounting-shaped webhook
{
  "request_id": "13f84f05-7c2a-4e9b-9f1d-2a6b8c0e4d11",
  "device_id": "5eb7fd5c-2a1b-4c3d-9e8f-7a6b5c4d3e2f",
  "visitor_id": "161dfbad-8e7f-4a6b-9c5d-0e1f2a3b4c5d",
  "user_hid": "8a9f...hashed-account-id",
  "public_ip": { "ip": "203.0.113.42", "country": "US" },
  "local_ip": { "ip": "192.168.1.24", "country": "US" },
  "risk_score": 20,
        "signals": [
    { "name": "Proxy", "weight": 10 },
    { "name": "Datacenter IP", "weight": 10 }
  ],
  "detection_flags": { "proxy": true, "datacenter_ip": true, "ip_mismatch": true },
  "observed_at": "2026-06-16T18:00:45Z"
}
4

Link accounts by the durable DeviceID

None of the signals say “multi-accounting” — they tell you a single session is masked. The account-linking is yours: count the distinct user_hid values that have appeared on one device_id across History, and use the per-session score only to escalate a session that is both masked and on an already-crowded device.
api/account-action.js
app.post('/api/account-action', async (req, res) => {
  const { accountId, shieldRequestId } = req.body;

  // 1. Read the scored result for this session (cache, then History fallback).
  const shield = await waitForScore(shieldRequestId, 2000);
  const score    = shield?.score ?? 0;        // 0–100; default 0 if not yet in
  const deviceId = shield?.device_id;         // the durable linking key

  // 2. The all-zero DeviceID is "no device" (JS-disabled / blocked), not a new
  //    machine. Skip the link count for it and decide on the score alone.
  const NIL = '00000000-0000-0000-0000-000000000000';
  const usableDevice = deviceId && deviceId !== NIL;

  // 3. Count the distinct accounts that have appeared on this one device.
  //    An anonymous visit stores the literal "anonymous" — drop it before
  //    counting. History caps at 100 rows per page, so for a high-traffic
  //    device track the device->account set in your own store (see note).
  let accountsOnDevice = 1;
  if (usableDevice) {
    const rows = await shieldHistory('device_id', deviceId, 100);
    const accounts = rows
      .map((r) => r.user_hid)
      .filter((h) => h && h !== 'anonymous');
    accountsOnDevice = new Set(accounts).size;
  }

  // 4. YOUR code owns the verdict. Branch on the count and the band, never on
  //    a signal label string. Tune YOUR_ACCOUNT_LIMIT against your own traffic.
  if (usableDevice && accountsOnDevice >= YOUR_ACCOUNT_LIMIT) {
    return res.status(200).json({ requireVerification: true, reason: 'many_accounts_one_device' });
  }
  if (score >= 60) {
    return res.status(200).json({ requireVerification: true, reason: 'anonymity' });
  }

  return allow(req, res);
});
Read the device's account history
curl "https://account.shieldlabs.ai/api/v1/history/device_id/5eb7fd5c-2a1b-4c3d-9e8f-7a6b5c4d3e2f?limit=100" \
  -H "Authorization: Bearer sec_your_private_api_key"
A single History read returns at most 100 rows (the limit cap), newest first, so a one-page read can undercount a heavily farmed device. For high-traffic devices, paginate with offset, or — cleaner — upsert the user_hid into a per-device set in your own datastore as each webhook arrives. The dashboard Many Accounts on One Device Pattern counts over the full rolling window server-side and is the complement when you do not want to paginate. History reads through account.shieldlabs.ai are free.
5

Catch what spans browsers: link on the local network

A person using several genuinely separate browsers shows up as several devices. The Many Accounts on One Local IP Pattern catches what shares a network but not a browser. The webhook carries two IPs: public_ip is the public IP a VPN or proxy fakes freely, while local_ip is the visitor’s real local network address behind the mask. When their countries disagree, detection_flags.ip_mismatch is set to true — informational only, surfaced for your code, and it does not change the Risk Score (a raw difference can be benign, since mobile networks often route over different paths). The durable link here is the constant local_ip.ip, not the flag: for the farm behind one router or NAT, the public IP rotates every session but local_ip.ip stays constant, so you can correlate accounts on it straight from the webhook.
// History does not search by local_ip, so keep this set in your own store.
// local_ip is only present when the follow-up network check resolved.
const localKey = shield?.local_ip?.ip;
if (localKey) {
  accountsByLocalNetwork.add(localKey, shield.user_hid); // upsert per webhook
}
6

Tune to your product

A high score or a shared device is not a fraud verdict on its own — a family on one shared laptop, a shared office network, or a privacy browser can all produce these shapes. Decide on the account count plus the score plus your own context, start in a logging-only mode, and raise friction only where the data justifies it.

Test it

You do not need a real farm to confirm the link holds. Create or sign in to one account in your normal browser and note the device_id on the webhook. Then act as a fraudster would: clear cookies, open a private/incognito window, or switch to a second browser profile, and act again as a different account. The cookie_id and visitor_id change every time, but the same device_id returns, and the distinct-user_hid count off that device climbs with each run — exactly the count step 4 gates on. Toggling a VPN or proxy adds the matching anonymity signals to the session score without changing the durable DeviceID. A guide, not a rule. Layer the conditions: real multi-accounting trips more than one, and friction should rise as they stack.
Signal at the actionSuggested action
One account on the DeviceID, Clean / Low scoreAllow
One account on the DeviceID, Medium scoreAllow, log and watch the device
One account on the DeviceID, High scoreRequire verification before continuing
Device or local IP flagged Suspicious (Many Accounts on One Device / Local IP)Require verification, regardless of the session score
Device or local IP flagged DangerousHold and route to review
All-zero DeviceID (JS-disabled or blocked, score 90+)Decide on the score alone, skip the link count

Next

New Account Fraud

The create-time gate: join accounts to the DeviceID at registration to thin the farm before it acts.

Promo Abuse

The reward-time gate: count accounts behind one device at redemption to stop bonus and free-trial farming.

Bonus Abuse

Repeat signup and deposit bonuses claimed through duplicate accounts on one device.

Loyalty Fraud

Points and tier rewards farmed across many linked identities instead of genuine activity.
The mechanism here — link accounts by the durable DeviceID, read the per-session Risk Score and its signals, and watch the Patterns that count accounts per device and per local IP — is the same root behind free-trial abuse, affiliate fraud, and Sybil attacks.