Skip to main content
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 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 and grades how many wallets cluster on one device or network. Four layers answer four different questions:
LayerWhat it answersWhere you read itLatency
Identification”Is this the same device, even after cleared cookies, incognito, or a new IP?”The durable device_id on the webhook / History APIAbout a second
Anonymity detection”Is this claim masked or anonymous right now?”The signals array on the webhook / History APIAbout a second
Risk Score”How risky is the visit overall, as one 0-100 number?”risk_score on the webhook / History APIAbout a second
Patterns”How many wallets already cluster on this device or local IP?”Dashboard Patterns + exportBackground (~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) 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

1

Identify the claim session in the browser

Add the 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.
claim.html
<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>
2

Read the scored result on your server

The scored result arrives by webhook. Verify the X-Shield-Signature HMAC, cache it by request_id, and read it back with the shared waitForScore helper, or fall back to a History API read by request_id. The fields the claim decision needs:
{
  "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.
3

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 by device_id to reconstruct every wallet that device touched, then let your own code make the verdict.
Read a device's 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"
api/claim.js
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;
}
4

See the cluster over time

The per-session check catches a claim right now. The standing view is Patterns, graded server-side over a rolling window and exported as CSV or JSON.
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.
Many wallets claiming through one local network, even when each rotates its public IP. Catches a person spread across several devices behind one router.
History reads through account.shieldlabs.ai are free. For a high-volume airdrop, use the Patterns export as a denylist of flagged devices and local IPs, and reserve live device_id reads for the borderline claims worth the cost.
5

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.

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

Catch Multi-Accounting

The same one-person-many-accounts shape outside crypto: count the accounts behind a device at signup and at action time.

Stop Promo Abuse

Gate a per-customer reward on the account count behind the device — the web2 cousin of an airdrop farm.

Patterns

The dashboard view that grades “Many Accounts on One Device” and “Many Accounts on One Local IP” historically.

Webhooks

The signed payload your server reads, with the device_id, score, signals, and detection_flags fields the claim decision uses.