Stop points, tiers and member perks from being farmed or drained through linked or taken-over accounts.
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 network, or a real member whose balance someone else drains after taking over the account. ShieldLabs links each member account to the devices and network behind it and detects Multi-accounting, Account sharing and Account takeover on your members directly. The Device ID ties the “different” members together, so you see how many of them share one device.
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, or draining a real member’s points after taking over the account. 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.
ShieldLabs ties each earning or redemption to the member account behind it, detects High-Risk Events on your members, and scores the action itself. Four layers answer four questions:
Layer
What it answers
Where you read it
Latency
Account
”Is this member risky, and which devices and IPs is it linked to?”
History API by user_hid: the member’s worst band, its Device IDs and IPs
On demand
High-Risk Events
”Is this member multi-accounting, shared, or taken over?”
”How many members share this device, even after cleared cookies, incognito or a new IP?”
History API by device_id: the distinct accounts behind it
On demand
Identification
”Is this earning or redemption masked or automated right now?”
risk_score, the named signals and detection_flags on the webhook
About 300 ms
The anchor for the device count is the Device ID, derived server-side, so a cookie clear, a private window or a rotated VPN IP does not reset it. The distinct accounts behind one Device ID are the members behind one machine. When a farmer rotates the public IP through a VPN, the Local IP, the address the browser itself reports, can expose the network behind the mask.
The rule to apply: on every earning and redemption action, read the member’s account (its worst band and linked devices), the action’s risk_score and named signals, and the device_id. Count the distinct accounts behind one device_id with the History API, and behind one local_ip.ip in your own webhook store, 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 Risk Score (0-100) and the detection_flags: a masked action 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, compare public_ip.country with local_ip.country; detection_flags.ip_mismatch marks two different addresses and is informational (it does not change the Risk Score). ShieldLabs stops loyalty fraud by linking the accounts and scoring each action; you choose the action for each case. The steps below wire it up.
Add the snippet to your signed-in pages and pass a hashed User HID with checkAuthenticatedUser on every one of them. Users, account-level risk and all four High-Risk Events are built on it. Within one visit (while a page of your site stays open in the browser, across route changes in a single-page app and across open tabs), checkAnonymous and checkAuthenticatedUser run at most one identification every five minutes for the same user. A call inside that window posts nothing, counts nothing, and its onInitialized handler receives { status: "not_initialized" }. On a multi-page site, a full page load in the only open tab can start a new visit with its own identification. So where points are earned or a perk is claimed, call forceCheckAuthenticatedUser when the user starts filling the form for that action (its first focus): it runs an identification every time, keeps the current Session ID and restarts the five-minute window, so the action always posts its own request ID. onInitialized fires when the check starts, before the snippet has sent the identification, so do not navigate away inside it: store the request ID and let the form submit normally. Pass the member’s hashed account id, never a raw email.
claim-reward.html
<script type="module"> const mod = await import( 'https://cdn.shieldlabs.ai/snippet.js?publicKey=YOUR_PUBLIC_KEY' ); const form = document.getElementById('reward-form'); // The Device ID and Risk Score reach your server by webhook; // result.requestID joins them. onInitialized fires when the check starts, // before the snippet sends it, so it only stores the request ID and the form // submits normally. With no request ID, your backend holds the reward. // Pass the hashed account id, never a raw email. const identify = () => { mod.forceCheckAuthenticatedUser('8a9f-hashed-account-id', { onInitialized: (result) => { if (result.status === 'initialized') { document.getElementById('shieldlabs-request-id').value = result.requestID; } }, }); }; if (form.contains(document.activeElement)) identify(); // already focused (autofocus) else form.addEventListener('focusin', identify, { once: true });</script><form id="reward-form" method="POST" action="/api/loyalty/redeem"> <input type="hidden" id="shieldlabs-request-id" name="shieldlabsRequestId" /> <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 webhook with request_id, user_hid, device_id, visitor_id, risk_score, signals, detection_flags and observed_at. Cache it by request_id and read it back with the shared waitForScore helper, which falls 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 redemption is one identification. The member behind it has a history, and for a loyalty program it is usually a long one. The shared accountView helper reads the member’s earlier identifications from the History API by user_hid: the worst band across them shows how risky the member has been, and its distinct Device IDs and public IPs are the devices and networks it is linked to. When a Multi-accounting, Account sharing or Account takeover event arrives for a member through the API or webhooks, or when you review it in the analytics dashboard, record it against the account in your own system with the event and its confidence, and the handler in the next step reads that record.In the analytics dashboard, the member’s card shows the same view: its band for the selected period, its High-Risk Events, each pill coloured by its confidence, and each linked device, visitor and IP with the band of the identifications it shares with the account. User, device, visitor and IP cards describes each part.
The devices linked to one user in the analytics dashboard, with the band of the identifications they share.
4
Count the accounts behind the device and decide
The decision combines the member, the action’s risk signals and the account count behind the device. A masked action alone can be a real member on a corporate VPN; many accounts redeeming from one durable Device ID is the farm shape no single genuine member ever shows.
api/loyalty/redeem.js
import { app, waitForScore, band, accountView, accountsBehindDevice, NIL_DEVICE } from '../../shieldlabs-helpers.js';app.post('/api/loyalty/redeem', async (req, res) => { const { rewardId, shieldlabsRequestId } = req.body; const accountId = req.user.id; const userHid = req.user.hashedId; // the member's hashed id, as passed to the snippet // 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. The guard: read this action's identification. No identification is // unverified, never clean. const risk = await waitForScore(shieldlabsRequestId, 2000); if (!risk) { return res.json({ requireVerification: true, reason: 'no_identification' }); } if (risk.user_hid !== userHid) { return res.json({ requireVerification: true, reason: 'identification_mismatch' }); } if (risk.risk_score > 100) { // The 999 rate-limit marker. return res.json({ requireVerification: true, reason: 'rate_limit_marker' }); } // 3. The member: the High-Risk Event you recorded for it (your own store, // written when the event arrives through the API or webhooks or when you // review it in the analytics dashboard), then its earlier identifications. const mark = await memberMarks.get(userHid); // your own record, e.g. { event: 'Account takeover', confidence: 'high' } if (mark?.event === 'Account takeover') { // Points drain: the real member confirms it is them before any redemption. return mark.confidence === 'high' ? res.status(403).json({ error: 'redemptions_locked', reason: 'account_recovery' }) : res.json({ requireReauthentication: true, reason: 'takeover_review' }); } if (mark?.confidence === 'high') { return res.status(403).json({ error: 'reward_denied', reason: 'held_for_review' }); } // A failed History read is unverified: hold the reward. const member = await accountView(userHid, { excludeRequestId: risk.request_id }).catch(() => null); if (!member) return res.json({ requireVerification: true, reason: 'history_unavailable' }); if (mark || member.worstBand === 'Dangerous') { return res.json({ requireVerification: true, reason: 'risky_member' }); } // 4. An all-zero Device ID means no usable device signals reached ShieldLabs: // there is no device to count members on, so hold the reward. if (risk.device_id === NIL_DEVICE) { return res.json({ requireVerification: true, reason: 'no_device' }); } // 5. Distinct member accounts behind this one device ("anonymous" is not an account). const accountsOnDevice = await accountsBehindDevice(risk.device_id).catch(() => null); if (accountsOnDevice === null) { return res.json({ requireVerification: true, reason: 'history_unavailable' }); } if (accountsOnDevice >= YOUR_ACCOUNT_LIMIT) { return res.json({ requireVerification: true, reason: 'accounts_linked_to_device' }); } // 6. The action itself, from the band and the IP countries, never from a // label string. A masked network on a device other members use is the // farm tell (local_ip is null on the History fallback). const countriesDiffer = Boolean( risk.local_ip?.country && risk.public_ip?.country && risk.local_ip.country !== risk.public_ip.country ); if (band(risk.risk_score) === 'Dangerous' || (countriesDiffer && accountsOnDevice > 1)) { return res.json({ requireVerification: true, reason: 'risk_signals' }); } if (band(risk.risk_score) === 'Suspicious') { await flagForReview(accountId, risk.device_id, risk); // Suspicious: grant, but watch } // Trusted, a clean member, one account on the device: award the reward. return grantReward(req, res);});
5
See the spread over time
The per-action check catches a redemption right now. ShieldLabs also detects three High-Risk Events that matter for loyalty programs, each at Medium or High confidence, and they are available in the analytics dashboard, the API and webhooks:
Multi-accounting
Several member accounts run by one person, linked through the devices and network they share: the core farming shape. The confidence depends on the combination of evidence.
Account sharing
One member account used from several distinct devices: the tier or status-abuse shape, where one identity is pushed up by activity from many people.
Account takeover
An existing member account appearing in a new environment that points to someone else using it: the points-drain shape, where someone else redeems the member’s balance.
All three need the hashed User HID, which you pass with checkAuthenticatedUser on every signed-in page and with forceCheckAuthenticatedUser on the action. When one of these events arrives for a member, act on the account; you choose the action for each case. At the earning or redemption itself, the Risk Score and risk signals of the identification remain the input.On Overview, the Users and High-Risk Events panels count your members with each event at Medium and High confidence for the selected period, and each event tile opens Analytics with that event as a filter; switch to the Users tab to list the members who have it. Investigate a risky user walks through the review.
Users and High-Risk Events on the Overview screen of the analytics dashboard, counted for the users active in the selected period.
History API reads never count against your included identifications. For high-volume earning flows, keep your own counters (members per Device ID and per local_ip.ip from the webhook) as the fast path, and reserve live History reads for the perks that are expensive to give away by mistake. The History API has no Local IP search, so the Local IP count always comes from your own store.
6
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 VPN can reach the Suspicious band, so decide on the Risk Score plus the detection_flags plus the account count plus your own context, never the number alone.
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 or open a private window, 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-account count on that device climbs with each run. A second browser gets its own Device ID; the network the accounts share can still link them. Toggling a VPN lights up the risk signals and flips the matching detection_flags, all without changing the Device ID.Then search Analytics in the analytics dashboard for that Device ID and open it: Linked accounts lists every account you used in the test, each with the band of its identifications on that device.