Skip to main content
A signup or deposit bonus is meant to be claimed once per real person. Bonus hunters get around that by spinning up a string of fresh accounts — new email, new cookie, often a private window and a rotated IP — and claiming the same bonus from each one. On the wire that looks like several “different” customers, but the bonus-claim sessions trace back to one DeviceID you have seen before.

What is bonus abuse?

Bonus abuse is the repeated claiming of a per-customer promotion — a signup bonus, a deposit-match, or free credit — through duplicate accounts that pretend to be new players. It is common in iGaming and rewards programs, where the bonus has direct cash value and one person can profitably farm dozens of accounts.

How ShieldLabs surfaces it

ShieldLabs resolves each claim to a set of identifiers: the per-claim RequestID, the cookie-scoped VisitorID, your hashed UserHID for the account, and the durable, server-derived DeviceID that survives a cookie clear, incognito, and IP rotation. A hunter clears cookies, opens a private window, and rotates VPN IPs between accounts precisely so each claim looks new, so counting cookies or public IPs lets the farm right through. The DeviceID holds steady underneath and lets your code see how many “new” players actually share one machine. ShieldLabs also returns a per-request Risk Score (0–100). When a hunter masks the connection, the anonymity signals fire, and the real network IP (local_ip.ip) can reveal the network behind the public public_ip.ip they rotate. When their countries disagree, detection_flags.ip_mismatch is set to true (informational only, surfaced for your code; it does not change the score, and can be benign on mobile networks). The durable tell is the constant local_ip itself, the Local IP the patterns correlate on, so a farm rotating VPN exits still collapses to one local_ip. The dashboard grades the relationship over time with the Many Accounts on One Device and Many Accounts on One Local IP patterns; your claim code enforces your per-customer policy.

Stop bonus abuse

The rule your code applies, wired up in ## Build it below: read the claim session’s score and its signals breakdown for masking, and read the durable DeviceID plus the real network IP (local_ip.ip) to count how many distinct UserHID accounts have already claimed off that one machine or local network. Then:
  • Credit the bonus when the score is low and the device is fresh.
  • Hold it for verification when the claim is masked, when the Many Accounts on One Device or Many Accounts on One Local IP pattern flags the entity, or when the live account count crosses your per-customer cap.
The outcome: a hunter who clears cookies, opens a private window, and rotates VPN exits still resolves to one DeviceID and one local_ip, so the bonus pauses for review instead of paying out, while a genuine new player goes through.

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 claim session

Add the snippet to the page where the bonus is claimed — the “claim bonus” button, the deposit form with a bonus code, the welcome-offer screen. Re-identify on the action so you score the session that is actually claiming, not a stale page load. Pass the account’s hashed id, never a raw email. Use forceCheckAuthenticatedUser here (not checkAuthenticatedUser): a bonus claim is a sensitive, value-bearing action, so resetting the visit session first gives you a fresh score keyed to a requestID you can act on, per the snippet methods.
claim-bonus.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.
  // forceCheckAuthenticatedUser resets the visit session first, so the claim
  // starts a fresh session keyed to a requestID you can act on.
  // The callback's first arg is the client IP the server saw (not a score);
  // the score is read server-side via the webhook or History API.
  mod.forceCheckAuthenticatedUser('8a9f-hashed-account-id', (ip, requestID) => {
    document.getElementById('shield-request-id').value = requestID;
  });
</script>

<form method="POST" action="/api/claim-bonus">
  <input type="hidden" id="shield-request-id" name="shieldRequestId" />
  <input type="text" name="bonusCode" placeholder="Bonus code" />
  <button type="submit">Claim bonus</button>
</form>
3

Read the scored result on your server

The score arrives on the webhook. Verify the X-Shield-Signature HMAC, then cache it by request_id. Your claim endpoint reads it back with the shared waitForScore helper from the Use Case Tutorials, or falls back to a History API read by request_id. The fields that matter are device_id, user_hid, score, signals, detection_flags, and the two IPs — public_ip (public, VPN-fakeable) and local_ip (the live equivalent of the Local IP behind the Many Accounts on One Local IP pattern):
the webhook your handler caches
{
  "request_id": "13f84f05-7c2a-4e9b-9f1d-2a6b8c0e4d11",
  "device_id": "5eb7fd5c-2a1b-4c3d-9e8f-7a6b5c4d3e2f",
  "visitor_id": "161dfbad-8e7f-4a6b-9c5d-0e1f2a3b4c5d",
  "user_hid": "a1b2c3d4hasheduserid",
  "public_ip": { "ip": "203.0.113.42", "country": "NL" },
  "local_ip": { "ip": "10.4.7.19", "country": "PL" },
  "risk_score": 20,
    "signals": [
    { "name": "Datacenter IP", "weight": 10 },
    { "name": "Abuser Flag", "weight": 10 }
  ],
  "detection_flags": { "ip_mismatch": true, "datacenter_ip": true, "abuser": true },
  "observed_at": "2026-06-16T18:00:45Z"
}
4

Count the accounts behind the device and decide

This is where your code owns the verdict. Link the related accounts on the durable device_id: read the History API by device_id and count the distinct user_hid values it has touched — that count is the number of accounts behind the machine. Combine it with the session score, then decide grant, verify, or deny.
api/claim-bonus.js
app.post('/api/claim-bonus', async (req, res) => {
  const { accountId, bonusCode, shieldRequestId } = req.body;

  // 1. Your normal bonus checks first (code valid, not already claimed by this
  //    account, within the campaign window).
  if (!(await bonusIsClaimable(bonusCode, accountId))) {
    return res.status(409).json({ error: 'bonus_not_claimable' });
  }

  // 2. Read the scored result for this session (webhook cache, then History
  //    fallback). waitForScore is the shared helper.
  const shield = await waitForScore(shieldRequestId, 2000);
  const score    = shield?.score ?? 0;        // 0-100 anonymity of this claim
  const deviceId = shield?.device_id;

  // 3. The all-zero DeviceID is "no device", not a new one. A blocked or
  //    JS-disabled browser returns it with a score of 90 or higher — gate on the
  //    score and skip the device count for it.
  const NIL = '00000000-0000-0000-0000-000000000000';
  const usableDevice = deviceId && deviceId !== NIL;

  // 4. Link related accounts on the durable device_id: how many distinct
  //    accounts has this one machine already used to claim the bonus?
  let accountsOnDevice = 0;
  if (usableDevice) {
    const rows = await shieldHistory('device_id', deviceId, 100);
    accountsOnDevice = new Set(rows.map((r) => r.user_hid).filter(Boolean)).size;
  }

  // 5. YOUR code decides. Branch on the score band and the account count,
  //    never on a signal label string.
  if (score >= 60 || accountsOnDevice >= YOUR_ACCOUNT_LIMIT) {
    // Masked claim, or one device farming many accounts: hold for verification.
    return res.status(200).json({ requireVerification: true, reason: 'bonus_already_claimed_on_device' });
  }

  // Clean enough and a fresh device: credit the bonus.
  return grantBonus(req, res);
});
shieldHistory is the shared History API read from the Use Case Tutorials, here keyed by device_id. The underlying call:
Read a device's claim 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 determined hunter using several genuinely separate browsers shows up as several devices, since the DeviceID is browser-bound. The Many Accounts on One Local IP pattern closes that gap: ten accounts claiming through one local IP is a strong shape even when each reports a different device. Use the pre-computed pattern export as a fast denylist; reserve live device_id reads for the borderline, high-value claims.
5

Decide and tune

Verify, do not hard-deny on the score alone — a real new player on a corporate VPN, a privacy browser, or a shared household network can score high or share a device with a relative. Holding a suspicious claim for a quick verification step keeps the genuine player in while still stopping the farm. Start in logging-only mode, watch how real claims distribute, then tune against your own traffic before you tighten thresholds.

Test it

You do not need a real farm to see this hold. Claim the bonus once in your normal browser and note the device_id on the webhook. Then play the hunter: clear cookies, open a private/incognito window, or switch to a second browser profile, and claim again as a different account. The cookie_id and visitor_id change on each run, but the same device_id returns, and the distinct-user_hid count off that device climbs with each claim — which is exactly the count your handler gates on. Toggling a VPN or switching networks should also light up the anonymity signals on the claiming session without changing the durable DeviceID. A guide, not a rule. Layer the score with the account count and tune against your own traffic.
Signal at the bonus claimSuggested action
Clean / Low score, no repeat deviceGrant the bonus
Medium score, no pattern flagGrant, but log and watch the device
High scoreHold for verification before crediting
Device or local IP flagged SuspiciousRequire verification, regardless of the session score
Device or local IP flagged DangerousDeny the bonus and route to review
All-zero DeviceID (blocked or JS-disabled, score 90+)Hold for verification on the score alone

Next

Promo Abuse

The sibling reward gate: coupons, referral credit, and trial resets claimed once per customer, counted off the same durable DeviceID.

New Account Fraud

Thin the farm at registration before it ever reaches the bonus, by joining each new account to its DeviceID.
Wire the signup tutorial for the create-account moment and treat this page as the bonus-time gate on top of it. For the mechanics underneath: Identifiers explains why the DeviceID survives a cookie clear, Anonymity signals lists every signal that can ride on a masked claim, Risk Scoring defines the 0-100 score and its bands, Patterns covers the dashboard relationship grading, and Webhooks gives the exact payload your handler reads.