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

# Multi-Accounting

> Learn how to detect and prevent multi-accounting: link many accounts back to one person through a shared device and network.

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](/features/identification)** 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)](/features/risk-scoring) and its [signals](/features/anonymity-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](/features/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).

<Note>
  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](/features/patterns) or its CSV/JSON export. An entity below the Suspicious threshold is the unflagged baseline and is never recorded.
</Note>

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

<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 session">
    Add the [snippet](/setup/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.

    ```html account-action.html theme={null}
    <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>
    ```
  </Step>

  <Step title="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`](/glossary#detection-flags) (full schema in the [webhook reference](/api/webhooks)). Verify `X-Shield-Signature` on the raw body, respond fast, and cache the result by `request_id` with the shared [`waitForScore` helper](/use-case). If a webhook is ever missed, that helper falls back to a [History API](/api/server-api) read by `request_id`.

    ```json A multi-accounting-shaped webhook theme={null}
    {
      "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"
    }
    ```
  </Step>

  <Step title="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](/api/server-api), and use the per-session score only to escalate a session that is both masked and on an already-crowded device.

    ```js api/account-action.js theme={null}
    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);
    });
    ```

    ```bash Read the device's account history theme={null}
    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"
    ```

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

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

    ```js theme={null}
    // 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
    }
    ```
  </Step>

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

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

## Recommended starting policy

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 action                                                               | Suggested action                                      |
| ---------------------------------------------------------------------------------- | ----------------------------------------------------- |
| One account on the DeviceID, Clean / Low score                                     | Allow                                                 |
| One account on the DeviceID, Medium score                                          | Allow, log and watch the device                       |
| One account on the DeviceID, High score                                            | Require 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 **Dangerous**                                           | Hold and route to review                              |
| All-zero DeviceID (JS-disabled or blocked, score 90+)                              | Decide on the score alone, skip the link count        |

## Next

<CardGroup cols={2}>
  <Card title="New Account Fraud" icon="user-plus" href="/use-case/new-account-fraud">
    The create-time gate: join accounts to the DeviceID at registration to thin the farm before it acts.
  </Card>

  <Card title="Promo Abuse" icon="ticket" href="/use-case/promo-abuse">
    The reward-time gate: count accounts behind one device at redemption to stop bonus and free-trial farming.
  </Card>

  <Card title="Bonus Abuse" icon="gift" href="/use-case/bonus-abuse">
    Repeat signup and deposit bonuses claimed through duplicate accounts on one device.
  </Card>

  <Card title="Loyalty Fraud" icon="award" href="/use-case/loyalty-fraud">
    Points and tier rewards farmed across many linked identities instead of genuine activity.
  </Card>
</CardGroup>

The mechanism here — link accounts by the durable [DeviceID](/features/identification), read the per-session [Risk Score](/features/risk-scoring) and its [signals](/features/anonymity-signals), and watch the [Patterns](/features/patterns) that count accounts per device and per local IP — is the same root behind [free-trial abuse](/use-case/free-trial-abuse), [affiliate fraud](/use-case/affiliate-fraud), and [Sybil attacks](/use-case/sybil-attack).
