Fake and duplicate accounts are how most abuse starts: free trials claimed again and again, sign-up bonuses farmed, bans evaded, reviews and votes faked. Each account looks new, with a new email address. The device behind them usually is not.
Where to identify
| Moment | Tag | Why |
|---|---|---|
| Submitting the sign-up form | signup | Decide before the account exists. |
| Activating a trial or a free allowance | trial-start | Where a fake account turns into a cost, if activation is separate from sign-up. |
Identify in the client
import { load } from '@fingerly/web-js'
const fingerly = await load({ apiKey: 'fly_pk_us_production_…' })
async function submit() {
let requestId: string | undefined
try {
({ requestId } = await fingerly.identify({ tag: 'signup' }))
} catch {
// Carry on: your server treats a missing request ID as missing evidence.
}
await fetch('/api/signup', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email, requestId }) })
}
Read the event on your server
Every recipe starts the same way: read the stored event with a secret key, and refuse to trust it unless it belongs to this action, is recent, and has not been used before. Fingerly does not stop a request ID from being read twice, so the one-time check is yours: any store with an atomic "add if absent" works, such as Redis SET with NX and a ten-minute expiry.
// fingerly.server.ts
import { load, FingerlyAPIError } from '@fingerly/node'
const fingerly = load({ secretKey: process.env.FINGERLY_SECRET_KEY! })
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
async function readEvent(requestId: string) {
for (let attempt = 1; ; attempt++) {
try {
return await fingerly.events.get(requestId)
} catch (error) {
const status = error instanceof FingerlyAPIError ? error.status : undefined
if (status === 404 && attempt < 4) await sleep(attempt * 250) // not readable yet
else if (status === 404 || status === 422) return null
else throw error
}
}
}
/**
* The stored event behind a request ID, or null when there is nothing to
* trust: no ID, an unknown ID, another action's tag, too old, or used before.
*/
export async function verifiedEvent(requestId: unknown, tag: string, maxAgeMs = 2 * 60 * 1000) {
if (typeof requestId !== 'string' || requestId === '') return null
const event = await readEvent(requestId)
if (!event || event.tag !== tag) return null
if (Date.now() - Date.parse(event.occurred_at) > maxAgeMs) return null
if (!(await usedRequestIds.add(event.request_id))) return null // your store: true only the first time
return event
}
/** Whether any of these signal groups fired. */
export const fired = (event: { triggers: Array<{ signal: string }> }, ...groups: string[]) =>
event.triggers.some((trigger) => groups.includes(trigger.signal))
verifiedEvent returns nothing when the client could not identify the visitor at all. Treat that as missing evidence: the policies below add friction a real customer can pass, rather than refusing outright.
Decide
import { fired, verifiedEvent } from './fingerly.server'
export async function signUpDecision(requestId: unknown) {
const event = await verifiedEvent(requestId, 'signup')
if (!event) return { action: 'verify' }
const existing = await accounts.countByVisitor(event.visitor_id, { days: 30 })
if (existing >= 3) return { action: 'refuse', event }
if (fired(event, 'device_farm', 'bot', 'android_emulator', 'ios_simulator', 'cloned_app')) {
return { action: 'review', event }
}
if (existing >= 1 || event.suspect_level !== 'low') return { action: 'verify', event }
if (fired(event, 'fingerprint_suppressed')) return { action: 'verify', event }
return { action: 'allow', event }
}
export async function signUp(email: string, requestId: unknown) {
const { action, event } = await signUpDecision(requestId)
if (action === 'refuse') return { error: 'refused' }
// Keep the device with the account, and hold it for review or verification.
return accounts.create({ email, status: action, visitorId: event?.visitor_id, signupRequestId: event?.request_id })
}
A starting policy
| Situation | Action |
|---|---|
| The device created 3 or more accounts in 30 days | Refuse, or create the account without its free benefits. |
device_farm, bot, android_emulator, ios_simulator or cloned_app fired | Create the account on hold for review. |
The device already has an account, the level is above low, or the request is unscored | Ask for verification, such as a phone number, before granting benefits. |
fingerprint_suppressed fired, or no usable identification | Ask for verification. The device cannot be counted. |
| Otherwise | Allow. |
Pick the limit that fits your product. A family sharing a tablet may reasonably create two accounts; a device creating twenty is not a household.
Signals that matter here
| Group | Why it matters for sign-ups |
|---|---|
device_farm | Devices that look mass-provisioned or freshly reset, or share traits with many devices at once. |
bot | Sign-up forms filled by scripts. |
android_emulator, ios_simulator, virtual_machine | Accounts created in bulk from virtual devices. |
cloned_app | Several copies of your app on one phone, one per account. |
incognito_mode, privacy_settings | Weak on their own: many real customers browse privately. Useful in combination. |
Link existing accounts
Once visitor_id is stored with each account, accounts that share a device are one query away. Use it when you ban an account, to review its siblings, and when you investigate abuse after the fact.
Roll it out
- Observe first. Run the check and log the decision it would have made, next to what actually happened, for a week or two.
- Tune. Look at the sessions the policy would have stopped in Identification > Events, and adjust risk weights and the thresholds in your own code until they match what you see.
- Enforce gradually. Turn on the friction a real customer can pass before the outright blocks.