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

# Sybil Attack

> Learn how to detect and prevent Sybil attacks: tie many wallets or identities back to one actor before an airdrop, vote, or quota pays out.

A Sybil attack is one person wearing many faces: in a crypto airdrop, a governance vote, or a per-person quota, the rule is one human, one wallet — but the attacker spins up dozens of wallets to claim the reward many times over. They all trace back to the same machine or local network. ShieldLabs gives you the durable [DeviceID](/features/identification) under every wallet, so your eligibility code can see how many wallets actually sit behind one device before it pays out.

## What is a Sybil attack?

A Sybil attack is when a single actor forges many distinct identities — wallets, addresses, or accounts — to gain disproportionate influence over a system that assumes each identity is a separate person. It is the standard way airdrops get farmed, on-chain votes get swayed, and one-per-customer quotas get drained.

## How ShieldLabs surfaces it

ShieldLabs resolves each claim to a set of [identifiers](/features/identification) and grades how many wallets cluster on one device or network. Four layers answer four different questions:

| Layer                   | What it answers                                                                | Where you read it                                                                        | Latency               |
| ----------------------- | ------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------- | --------------------- |
| **Identification**      | "Is this the same device, even after cleared cookies, incognito, or a new IP?" | The durable `device_id` on the [webhook](/api/webhooks) / [History API](/api/server-api) | About a second        |
| **Anonymity detection** | "Is this claim masked or anonymous right now?"                                 | The `signals` array on the [webhook](/api/webhooks) / [History API](/api/server-api)     | About a second        |
| **Risk Score**          | "How risky is the visit overall, as one 0-100 number?"                         | `risk_score` on the [webhook](/api/webhooks) / [History API](/api/server-api)            | About a second        |
| **Patterns**            | "How many wallets already cluster on this device or local IP?"                 | [Dashboard Patterns](/features/patterns) + export                                        | Background (\~10 min) |

The anchor for the rest is the durable **DeviceID** — derived server-side from the browser environment, not stored, so a cookie clear, incognito window, or rotated VPN IP does not reset it. Each forged wallet carries its own hashed account id (**UserHID**); the cookie-bound **VisitorID** resets on every claim, but the DeviceID holds steady, and the distinct UserHID count behind it is how many "separate" wallets sit behind one machine. When the person rotates the public IP per wallet, the real network IP (`local_ip`) still exposes the network behind the exit, so wallets group on one Local IP.

## Prevent Sybil attacks

The rule your eligibility code applies: read the durable `device_id` and your own `user_hid` (the hashed wallet) on every claim, and read `local_ip.ip` for the local-network case. Count the distinct `user_hid` values behind one `device_id` (and behind one `local_ip.ip`), and when that count crosses your one-human-one-wallet limit, route the claim to verification or reject it instead of paying out again. Weigh the session [Risk Score (0–100)](/features/risk-scoring) and its `signals` as evidence of masking: a single clean wallet pays out, while a machine or network already behind a cluster of wallets, or a high-band masked claim, is held. When the person masks location, `public_ip` and `local_ip` disagree, `detection_flags.ip_mismatch` is set to `true`. ShieldLabs surfaces the count and the score; your claim endpoint owns the pay, verify, or reject. The steps below wire it up.

## Build it

<Steps>
  <Step title="Identify the claim session in the browser">
    Add the [snippet](/setup/snippet) to the page where the wallet claims the reward (the claim button, vote screen, or quota form). Use `forceCheckAuthenticatedUser`, not `checkAuthenticatedUser`: it resets the visit session so this call starts a fresh requestID for the claim moment, rather than sharing an earlier page load. Pass the wallet address as a **hashed** id through `user_hid`, never a raw address.

    ```html claim.html theme={null}
    <script type="module">
      const mod = await import(
        'https://cdn.shieldlabs.ai/snippet.js?publicKey=YOUR_PUBLIC_KEY'
      );
      // Pass a hash of the connected wallet address, never the raw address.
      // The callback hands you the requestID to correlate with the score.
      mod.forceCheckAuthenticatedUser(hashWallet(walletAddress), (ip, requestID) => {
        document.getElementById('shield-request-id').value = requestID;
      });
    </script>

    <form method="POST" action="/api/claim">
      <input type="hidden" id="shield-request-id" name="shieldRequestId" />
      <button type="submit">Claim</button>
    </form>
    ```
  </Step>

  <Step title="Read the scored result on your server">
    The scored result arrives by [webhook](/api/webhooks). Verify the `X-Shield-Signature` HMAC, cache it by `request_id`, and read it back with the shared [`waitForScore` helper](/use-case), or fall back to a [History API](/api/server-api) read by `request_id`. The fields the claim decision needs:

    ```json 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": "a1b2c3d4hashedwallet",
      "public_ip": { "ip": "185.220.101.42", "country": "NL" },
      "local_ip": { "ip": "198.51.100.23", "country": "DE" },
      "connection_type": "proxy",
      "risk_score": 20,
            "signals": [
        { "name": "Proxy", "weight": 10 },
        { "name": "Datacenter IP", "weight": 10 }
      ],
      "detection_flags": { "proxy": true, "datacenter_ip": true, "vpn": false, "ip_mismatch": true },
      "observed_at": "2026-06-16T18:00:45Z"
    }
    ```

    `public_ip` is the public IP and country a VPN can fake; `local_ip` is the real network IP behind it. Group claims by `local_ip.ip` as a second key alongside `device_id`: wallets that share one Local IP across different devices and public IPs are the network-level Sybil shape. Keep the Local IP server-side; it is for your own logic, not for end users.
  </Step>

  <Step title="Count the wallets behind the device and decide">
    The score tells you whether one session looks masked; it does not tell you how many wallets sit behind the machine — that is the count off the durable `device_id`. Read the [History API](/api/server-api) by `device_id` to reconstruct every wallet that device touched, then let your own code make the verdict.

    ```bash Read a device's 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"
    ```

    ```js api/claim.js theme={null}
    app.post('/api/claim', async (req, res) => {
      const { walletAddress, shieldRequestId } = req.body;

      // 1. Your normal eligibility checks first (wallet eligible, not already
      //    paid, within the claim window).
      if (!(await walletIsEligible(walletAddress))) {
        return res.status(409).json({ error: 'wallet_not_eligible' });
      }

      // 2. Read the scored result for this session (snake_case fields).
      const shield   = await waitForScore(shieldRequestId, 2000);
      const score    = shield?.score ?? 0;        // 0-100, default to 0 if not yet in
      const deviceId = shield?.device_id;
      const flags    = shield?.detection_flags ?? {};

      // 3. Count distinct wallets behind this device. The all-zero DeviceID is
      //    "no device", not a real one — skip the count and decide on the score.
      const NIL = '00000000-0000-0000-0000-000000000000';
      const usableDevice = deviceId && deviceId !== NIL;
      const walletsOnDevice = usableDevice ? await walletsBehindDevice(deviceId) : 0;

      // 4. YOUR code owns the verdict. Branch on the band and the count, never
      //    on a signal label string. ip_mismatch is a masked-claim tell.
      if (walletsOnDevice >= YOUR_DEVICE_WALLET_LIMIT) {
        return res.json({ status: 'review', reason: 'many_wallets_one_device' });
      }
      if (score >= 60 || flags.ip_mismatch) {
        return res.json({ status: 'verify', reason: 'anonymity' });
      }

      // Clean session, no wallet cluster: pay out.
      return grantAirdrop(walletAddress, res);
    });

    // Distinct hashed wallets seen on one machine, from a device_id history read.
    async function walletsBehindDevice(deviceId) {
      const rows = await shieldHistory('device_id', deviceId, 100);
      return new Set(rows.map((r) => r.user_hid).filter(Boolean)).size;
    }
    ```
  </Step>

  <Step title="See the cluster over time">
    The per-session check catches a claim right now. The standing view is [Patterns](/features/patterns), graded server-side over a rolling window and exported as CSV or JSON.

    <AccordionGroup>
      <Accordion title="Many Accounts on One Device" icon="mobile-screen">
        One device linked to many distinct wallets: the core Sybil shape. The grouping identity is the durable DeviceID. It grades **Suspicious**, then **Dangerous**, as the wallet count climbs.
      </Accordion>

      <Accordion title="Many Accounts on One Local IP" icon="network-wired">
        Many wallets claiming through one local network, even when each rotates its public IP. Catches a person spread across several devices behind one router.
      </Accordion>
    </AccordionGroup>

    <Note>
      History reads through `account.shieldlabs.ai` are free. For a high-volume airdrop, use the [Patterns](/features/patterns) export as a denylist of flagged devices and local IPs, and reserve live `device_id` reads for the borderline claims worth the cost.
    </Note>
  </Step>

  <Step title="Tune to your claim traffic">
    Start in logging-only mode and watch how real claims distribute before you raise friction. A real participant on a corporate VPN or a privacy browser can land in the High band, and one wallet on a shared office network is not a farm — decide on the score, its `signals`, and the wallet count together.
  </Step>
</Steps>

## Test it

You do not need a real farm to see this work. Connect a wallet and claim once in your normal browser, and note the `device_id` on the webhook. Then play the Sybil attacker: clear cookies, open a private window, or switch to a second browser profile, and claim again with a different wallet. The `cookie_id` and `visitor_id` change each time, but the **same `device_id` returns**, and the distinct-`user_hid` count off that device climbs with every run — exactly the count your claim endpoint gates on. Toggling a VPN lights up the anonymity signals without changing the durable DeviceID.

## Next

<CardGroup cols={2}>
  <Card title="Catch Multi-Accounting" icon="users" href="/use-case/multi-accounting">
    The same one-person-many-accounts shape outside crypto: count the accounts behind a device at signup and at action time.
  </Card>

  <Card title="Stop Promo Abuse" icon="ticket" href="/use-case/promo-abuse">
    Gate a per-customer reward on the account count behind the device — the web2 cousin of an airdrop farm.
  </Card>

  <Card title="Patterns" icon="diagram-project" href="/features/patterns">
    The dashboard view that grades "Many Accounts on One Device" and "Many Accounts on One Local IP" historically.
  </Card>

  <Card title="Webhooks" icon="bolt" href="/api/webhooks">
    The signed payload your server reads, with the `device_id`, `score`, `signals`, and [`detection_flags`](/glossary#detection-flags) fields the claim decision uses.
  </Card>
</CardGroup>
