Stop one person from restarting your free trial with new accounts.
Trial abuse is one person taking your free trial again and again: a new email each run, the timer reset to zero, never a paid plan at the end. ShieldLabs links every trial account to the devices and network behind it, so you see how many “different” trial accounts one person runs, and detects Multi-accounting on those accounts directly. The Device ID holds through cleared cookies, incognito mode and IP changes between runs.
Free trial abuse is when one person repeatedly creates new accounts to re-claim a product’s free trial or free-tier quota without ever converting to paid. Each account looks like a distinct customer, but they all originate from one person behind a single device or local network, cycling identities to keep the free benefit running.
ShieldLabs ties each trial start to the account behind it, detects when one person runs many accounts, and scores the moment itself. Four layers answer four questions:
Layer
What it answers
Where you read it
Latency
Account
”Is this trial account risky, and which devices and IPs is it linked to?”
History API by user_hid: the account’s worst band, its Device IDs and IPs
On demand
High-Risk Events
”Is this account one of several run by one person?”
Multi-accounting on the user, at Medium or High confidence, in the analytics dashboard, the API and webhooks
When detected
Device
”How many trial accounts has this device started, even after cleared cookies, incognito or a new IP?”
History API by device_id: the distinct accounts behind it
On demand
Identification
”Is this trial start 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 from stable device characteristics, so clearing cookies, opening a private window or switching networks does not reset it. The Cookie ID and Visitor ID change when cookies are cleared; the Device ID holds, and the distinct accounts behind it are the trials one machine has started.
The trial policy: at trial start, read the new account, the trial start’s risk_score, and the distinct accounts behind its Device ID (History API) and its Local IP (your own webhook store), and hold the trial when the count crosses your trial limit. Accounts with a Multi-accounting event, whether it reached you through the API or webhooks or you reviewed it in the analytics dashboard, go to verification. Fold in the Risk Score (0-100) as weight: a clean device with a first trial passes, while a machine already cycling several accounts, or a Dangerous-band trial start, gets held. When a cycler hides behind a VPN to look like a new region, the Local IP, the address the browser itself reports, can expose the network behind a faked public_ip.country. ShieldLabs stops trial abuse by linking the accounts and scoring the moment; you choose allow, verify or deny for each case in your signup handler. The steps below wire it up.
Load the snippet on the page where the trial begins (the signup form or the “start free trial” button). A trial belongs to an account, so identify the new account right after you create it, when the “start trial” page opens: call forceCheckAuthenticatedUser with its hashed id. It runs an identification every time, keeps the current Session ID and restarts the five-minute window, so the trial start 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. From then on, pass a hashed User HID with checkAuthenticatedUser on every signed-in page. 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. That window is why the trial start itself uses forceCheckAuthenticatedUser. To score the signup form itself before the account exists, use forceCheckAnonymous as the signup tutorial shows.
start-trial.html
<script type="module"> const mod = await import( 'https://cdn.shieldlabs.ai/snippet.js?publicKey=YOUR_PUBLIC_KEY' ); // A fresh identification for the trial start, started when the page opens // so it is sent while the user reads it. 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 trial. // The new account's hashed id, rendered by your server after signup. // Never a raw email or user id. mod.forceCheckAuthenticatedUser('8a9f-hashed-account-id', { onInitialized: (result) => { if (result.status === 'initialized') { document.getElementById('shieldlabs-request-id').value = result.requestID; } }, });</script><form id="trial-form" method="POST" action="/api/start-trial"> <input type="hidden" id="shieldlabs-request-id" name="shieldlabsRequestId" /> <button type="submit">Start free trial</button></form>
2
Read the scored result on your server
The Risk Score arrives on the webhook. Verify the signature, 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. The fields your trial gate reads:
risk_score is the sum of the weights in signals, capped at 100; a value above 100 is the 999 rate-limit marker. The webhooks reference has the full schema and every detection_flags boolean.
3
Read the trial account
A trial start is one identification. For a brand-new account it is also the account’s whole history, so the device carries the weight: its earlier accounts are the trials it already started. When the account existed before the trial (a free tier that moves to a trial, a returning user), the shared accountView helper reads its earlier identifications from the History API by user_hid: the worst band across them, and the devices and networks it is linked to. An account with a Multi-accounting event, recorded in your own system when it arrives through the API or webhooks or when you review it in the analytics dashboard, goes to verification before the trial starts.In the analytics dashboard, the account’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.
4
Count the trials behind the device and decide
Read the trial start’s risk_score and its named signals, then count the accounts behind the durable device_id with the shared accountsBehindDevice helper: that is how many trials the machine has already started. A high Risk Score alone can be an honest prospect on a VPN; a high Risk Score and several accounts behind one device is the cycling shape worth gating.
api/start-trial.js
import { app, waitForScore, band, accountView, accountsBehindDevice, NIL_DEVICE } from '../shieldlabs-helpers.js';app.post('/api/start-trial', async (req, res) => { const { shieldlabsRequestId } = req.body; const accountId = req.user.id; const userHid = req.user.hashedId; // the new account's hashed id, as passed to the snippet // 1. Your normal eligibility checks first (not already trialed by this // account, within campaign window, terms accepted). if (!(await trialIsAvailable(accountId))) { return res.status(409).json({ error: 'trial_not_available' }); } // 2. The guard: read this trial start'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 account: your Multi-accounting mark ('medium' or 'high', your own // store), then its earlier identifications without this one. const marked = await markedAccounts.get(userHid); if (marked === 'high') { return res.status(403).json({ error: 'trial_denied', reason: 'held_for_review' }); } // A failed History read is unverified: hold the trial. const account = await accountView(userHid, { excludeRequestId: risk.request_id }).catch(() => null); if (!account) return res.json({ requireVerification: true, reason: 'history_unavailable' }); if (marked || account.worstBand === 'Dangerous') { return res.json({ requireVerification: true, reason: 'risky_account' }); } // 4. An all-zero Device ID means no usable device signals reached ShieldLabs: // there is no device to count trials on, so hold the trial. if (risk.device_id === NIL_DEVICE) { return res.json({ requireVerification: true, reason: 'no_device' }); } // 5. Distinct accounts behind this 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_TRIAL_LIMIT) { return res.json({ requireVerification: true, reason: 'trials_on_device' }); } // 6. The trial start itself. Branch on the band, never on a label string. if (band(risk.risk_score) === 'Dangerous') { return res.json({ requireVerification: true, reason: 'risk_signals' }); } // Trusted or Suspicious, a clean account and a fresh device: grant the trial. return grantTrial(req, res);});
For the local-network shape, keep your own count of trial accounts per local_ip.ip from the webhook: the History API searches by public IP (ip) and has no Local IP search. The Multi-accounting event also covers accounts that share a network.
5
See the spread over time
The per-trial check catches a trial right now. The standing view is the Multi-accountingHigh-Risk Event, which ShieldLabs detects directly with Medium or High confidence and makes available in the analytics dashboard, the API and webhooks. It covers both trial-cycling shapes:
Accounts linked by device
One device linked to many distinct accounts: the core trial-cycling shape. A fresh email and a private window do not reset the Device ID.
Accounts linked by network
Many accounts starting trials through the same network, even when each uses a fresh cookie and a different public IP. This catches a person who spreads across several separate browsers but still sits behind one router.
When a Multi-accounting event arrives for an account through the API or webhooks, or when you review it in the analytics dashboard, record it against the account in your own system, and the handler above holds its next trial; you choose the action for each case. At the trial start itself, the Risk Score and risk signals of the identification remain the input.In Analytics, open the Users tab and filter by the Multi-accounting High-Risk Event: every trial account with the event is listed with its band for the period. Investigate a risky user walks through the review.
Users with a Multi-accounting event in the analytics dashboard.
History API reads never count against your included identifications. For high-volume signup flows, keep your own counters (trial accounts per Device ID and per local_ip.ip from the webhook) as the fast path, and reserve live History reads for the borderline trials that are expensive to give away by mistake.
6
Tune to your product
Start in logging-only mode, watch how your real signups distribute, then set the device and Local IP limits in your trial check to match your trial terms before you raise friction. A real prospect on a corporate VPN can fire the same risk signals, so weigh the count, the Risk Score and your own context together.
You do not need a real farm to see this work. Start a trial once in your normal browser and note the device_id on the webhook. Then play the cycler: clear cookies or open a private window, and start a trial again as a different account. The cookie_id and visitor_id change every time, but the same device_id returns, and the distinct-account count on that device climbs with each run, exactly the count your gate reads. A second browser gets its own Device ID; the network the accounts share can still link them. Toggling a VPN adds the matching risk signals to the Risk Score 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.
One Device ID in the analytics dashboard, with every account linked to it.