Skip to main content
A loyalty program rewards genuine, repeated activity, so the payoff for faking it is steady: points, tier status, member pricing, and referral credit. The fraud shape is a cluster of “different” members, each with its own login and email, that all trace back to one machine or one local network. ShieldLabs gives you the durable DeviceID that ties those accounts together, so your earning and redemption code can see how many members actually share one device.

What is loyalty fraud?

Loyalty fraud is the gaming of a rewards or membership program — farming points, tiers, or member perks through multiple linked identities rather than real activity. One person runs several accounts to multiply signup bonuses, stack referral credit between their own profiles, or push a single identity into a higher reward tier than its genuine activity earns.

How ShieldLabs surfaces it

ShieldLabs resolves each session to a set of identifiers and grades how many accounts 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 earning or redemption 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 members 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, not stored in the browser, so a fresh cookie clear, incognito window, or rotated VPN IP does not reset it. The cookie-bound CookieID and VisitorID reset on every run; the DeviceID holds steady, and the distinct UserHID count behind it is the count of members behind one machine. When a farmer rotates the public IP through a VPN, the real network IP (local_ip) still exposes the local network behind the mask, so accounts that rotate their public IP still group on one Local IP.

Prevent loyalty fraud

The rule your code applies: read the durable device_id and your own user_hid on every earning and redemption action, 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 per-program limit, hold the perk for verification or deny it instead of paying the reward again. Weigh in the session Risk Score (0–100) and the detection_flags: a masked session reusing one device is the farm tell, while a clean, single-account device earns and redeems with no friction. When a farm masks its public IP, public_ip and local_ip disagree, so detection_flags.ip_mismatch is set to true (surfaced for your code; it does not change the score). ShieldLabs surfaces the count and the score; your earning and redemption handlers own the verdict. The steps below wire it up.

Build it

1

Identify on the action

Add the snippet to the page where points are earned or a perk is claimed, and re-identify on the action itself so you score the live session. Pass the member’s hashed account id (UserHID), never a raw email.
claim-reward.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.
  // `ip` is the client IP the server saw, not a score — the DeviceID and
  // Risk Score are read server-side via webhook. requestID correlates them.
  mod.checkAuthenticatedUser('8a9f-hashed-account-id', (ip, requestID) => {
    document.getElementById('shield-request-id').value = requestID;
  });
</script>

<form method="POST" action="/api/loyalty/redeem">
  <input type="hidden" id="shield-request-id" name="shieldRequestId" />
  <input type="text" name="rewardId" placeholder="Reward" />
  <button type="submit">Redeem points</button>
</form>
2

Read the scored result on the server

The scored result arrives by webhookrequest_id, device_id, visitor_id, user_hid, score, signals, detection_flags, scored_at. 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. Because the durable device_id is the grouping key, you also read the account’s neighbours: how many distinct accounts that one device has already touched.
The accounts behind one device
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"
3

Count the accounts behind the device and decide

The verdict combines the session’s anonymity with the account count behind the device. A masked session alone can be a real member on a corporate VPN; many accounts redeeming from one durable DeviceID is the farm shape no single genuine member ever shows.
api/loyalty/redeem.js
app.post('/api/loyalty/redeem', async (req, res) => {
  const { accountId, rewardId, shieldRequestId } = req.body;

  // 1. Your normal program checks first (member owns the points, perk is
  //    eligible, within the campaign window).
  if (!(await rewardIsRedeemable(rewardId, accountId))) {
    return res.status(409).json({ error: 'reward_not_redeemable' });
  }

  // 2. Read the scored result for this session (snake_case webhook body).
  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. The all-zero DeviceID is "no device" (JS disabled or blocked), not a
  //    new one. Skip the account count and decide on the score alone.
  const NIL = '00000000-0000-0000-0000-000000000000';
  const usableDevice = deviceId && deviceId !== NIL;

  // 4. Count distinct accounts behind this one durable device.
  let accountsOnDevice = 1;
  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 owns the verdict. Branch on the band, the count, and the
  //    IP-mismatch flag, never on a signal label string. ip_mismatch on a
  //    reused device is the masked-farm tell.
  const maskedFarm = flags.ip_mismatch && accountsOnDevice > 1;
  if (accountsOnDevice >= YOUR_ACCOUNT_LIMIT || score >= 60 || maskedFarm) {
    return res.json({
      requireVerification: true,
      reason: accountsOnDevice >= YOUR_ACCOUNT_LIMIT ? 'accounts_linked_to_device' : 'anonymity',
    });
  }
  if (score >= 30) {
    await flagForReview(accountId, deviceId, shield);   // Medium: grant, but watch
  }

  // Clean / Low, one account on the device: award the reward.
  return grantReward(req, res);
});
4

See the spread over time

The per-session check catches a redemption 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 accounts: the core farming shape. The grouping identity is the durable DeviceID. It grades Suspicious, then Dangerous, as the account count climbs.
Many accounts earning or redeeming through the same local network, even when each rotates its public IP. Catches a person spread across several browsers behind one router.
One member worked from a string of machines — the tier or status-abuse shape, where a single identity is pushed up by activity across many devices.
History reads through account.shieldlabs.ai are free. For high-volume earning flows, use the Patterns export as your denylist of farm devices and local IPs, and reserve live device_id reads for the perks that are expensive to give away by mistake.
5

Tune to your program

Start in logging-only mode, watch how real members distribute, then set your limits and raise friction as conditions stack. A real member on a corporate proxy or privacy browser can land in the High band, so decide on the score plus the detection_flags plus the account count plus your own context, never the number alone.

Test it

You do not need a real farm to see this work. Redeem a perk once in your normal browser and note the device_id on the webhook. Then play the farmer: clear cookies, open a private window, or switch to a second browser profile, and redeem again as a different member. 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 each run. Toggling a VPN lights up the anonymity signals and flips the matching detection_flags, all without changing the durable DeviceID. A guide, not a rule. Layer the conditions: a loyalty farm trips more than one, and friction should rise as they stack.
Signal at earning or redemptionSuggested action
Clean / Low score, one account on the deviceAward the reward
Medium score, one account on the deviceAward, but log and watch the device
High scoreRequire verification before granting
Many accounts on the device or local IP (over your limit)Require verification, regardless of session score
Device or local IP flagged Suspicious in the Patterns exportRequire verification
Device or local IP flagged DangerousDeny the perk and route to review
All-zero DeviceID, JavaScript-disabled visit (score 90+)Decide on the score alone, skip the account count
All-zero DeviceID, snippet blocked (no score reached the server)Fall back to the History API, treat as unverifiable, route to review

Next

Stop Multi-Accounting

Loyalty farming is a special case of one person running many accounts.

Promo Abuse

The reward-time gate for one-per-customer signup bonuses, coupons, and credits.

Referral Abuse via Sybils

When the linked identities exist to farm referral credit between each other.

Patterns

The dashboard view that grades many-accounts-on-one-device across all members.