[{"data":1,"prerenderedAt":165},["ShallowReactive",2],{"doc:\u002Fdocs\u002Fuse-cases\u002Fsign-up-abuse":3},{"page":4,"toc":155,"updated":164},{"path":5,"title":6,"seoTitle":7,"description":8,"blocks":9},"\u002Fdocs\u002Fuse-cases\u002Fsign-up-abuse","Sign-up abuse","Prevent Fake Accounts and Multi-accounting at Sign-up","Limit how many accounts one device can create, and send sign-ups from device farms, emulators and automation to verification or review.",[10,13,18,33,36,51,54,56,65,67,70,76,79,99,101,104,124,127,129,133,136,142],{"type":11,"text":12},"p","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.",{"type":14,"level":15,"text":16,"id":17},"heading",2,"Where to identify","where-to-identify",{"type":19,"columns":20,"rows":24},"table",[21,22,23],"Moment","Tag","Why",[25,29],[26,27,28],"Submitting the sign-up form","`signup`","Decide before the account exists.",[30,31,32],"Activating a trial or a free allowance","`trial-start`","Where a fake account turns into a cost, if activation is separate from sign-up.",{"type":14,"level":15,"text":34,"id":35},"Identify in the client","identify-in-the-client",{"type":37,"samples":38},"code",[39,43,47],{"label":40,"lang":41,"code":42},"JavaScript","ts","import { load } from '@fingerly\u002Fweb-js'\n\nconst fingerly = await load({ apiKey: 'fly_pk_us_production_…' })\n\nasync function submit() {\n  let requestId: string | undefined\n  try {\n    ({ requestId } = await fingerly.identify({ tag: 'signup' }))\n  } catch {\n    \u002F\u002F Carry on: your server treats a missing request ID as missing evidence.\n  }\n  await fetch('\u002Fapi\u002Fsignup', { method: 'POST', headers: { 'Content-Type': 'application\u002Fjson' }, body: JSON.stringify({ email, requestId }) })\n}",{"label":44,"lang":45,"code":46},"Swift","swift","let requestId = try? await fingerly.identify(tag: \"signup\").requestId\ntry await api.signUp(email: email, requestId: requestId)",{"label":48,"lang":49,"code":50},"Kotlin","kotlin","val requestId = runCatching { fingerly.identify(tag = \"signup\").requestId }.getOrNull()\napi.signUp(email, requestId)",{"type":14,"level":15,"text":52,"id":53},"Read the event on your server","read-the-event-on-your-server",{"type":11,"text":55},"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.",{"type":37,"samples":57},[58,61],{"label":59,"lang":41,"code":60},"Node.js","\u002F\u002F fingerly.server.ts\nimport { load, FingerlyAPIError } from '@fingerly\u002Fnode'\n\nconst fingerly = load({ secretKey: process.env.FINGERLY_SECRET_KEY! })\nconst sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))\n\nasync function readEvent(requestId: string) {\n  for (let attempt = 1; ; attempt++) {\n    try {\n      return await fingerly.events.get(requestId)\n    } catch (error) {\n      const status = error instanceof FingerlyAPIError ? error.status : undefined\n      if (status === 404 && attempt \u003C 4) await sleep(attempt * 250)   \u002F\u002F not readable yet\n      else if (status === 404 || status === 422) return null\n      else throw error\n    }\n  }\n}\n\n\u002F**\n * The stored event behind a request ID, or null when there is nothing to\n * trust: no ID, an unknown ID, another action's tag, too old, or used before.\n *\u002F\nexport async function verifiedEvent(requestId: unknown, tag: string, maxAgeMs = 2 * 60 * 1000) {\n  if (typeof requestId !== 'string' || requestId === '') return null\n\n  const event = await readEvent(requestId)\n  if (!event || event.tag !== tag) return null\n  if (Date.now() - Date.parse(event.occurred_at) > maxAgeMs) return null\n  if (!(await usedRequestIds.add(event.request_id))) return null   \u002F\u002F your store: true only the first time\n\n  return event\n}\n\n\u002F** Whether any of these signal groups fired. *\u002F\nexport const fired = (event: { triggers: Array\u003C{ signal: string }> }, ...groups: string[]) =>\n  event.triggers.some((trigger) => groups.includes(trigger.signal))",{"label":62,"lang":63,"code":64},"Python","python","# fingerly_server.py\nimport os\nimport time\nfrom datetime import datetime, timedelta, timezone\nfrom fingerly import Fingerly, FingerlyAPIError\n\nfingerly = Fingerly(secret_key=os.environ[\"FINGERLY_SECRET_KEY\"])\n\n\ndef _read_event(request_id):\n    for attempt in range(1, 5):\n        try:\n            return fingerly.events.get(request_id)\n        except FingerlyAPIError as error:\n            if error.status == 404 and attempt \u003C 4:\n                time.sleep(attempt * 0.25)  # not readable yet\n            elif error.status in (404, 422):\n                return None\n            else:\n                raise\n\n\ndef verified_event(request_id, tag, max_age=timedelta(minutes=2)):\n    \"\"\"The stored event behind a request ID, or None when there is nothing to trust.\"\"\"\n    if not isinstance(request_id, str) or not request_id:\n        return None\n\n    event = _read_event(request_id)\n    if event is None or event.tag != tag:\n        return None\n    if datetime.now(timezone.utc) - event.occurred_at > max_age:\n        return None\n    if not used_request_ids.add(event.request_id):  # your store: True only the first time\n        return None\n    return event\n\n\ndef fired(event, *groups):\n    \"\"\"Whether any of these signal groups fired.\"\"\"\n    return any(trigger.signal in groups for trigger in event.triggers)",{"type":11,"text":66},"`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.",{"type":14,"level":15,"text":68,"id":69},"Decide","decide",{"type":37,"samples":71},[72,74],{"label":59,"lang":41,"code":73},"import { fired, verifiedEvent } from '.\u002Ffingerly.server'\n\nexport async function signUpDecision(requestId: unknown) {\n  const event = await verifiedEvent(requestId, 'signup')\n  if (!event) return { action: 'verify' }\n\n  const existing = await accounts.countByVisitor(event.visitor_id, { days: 30 })\n\n  if (existing >= 3) return { action: 'refuse', event }\n  if (fired(event, 'device_farm', 'bot', 'android_emulator', 'ios_simulator', 'cloned_app')) {\n    return { action: 'review', event }\n  }\n  if (existing >= 1 || event.suspect_level !== 'low') return { action: 'verify', event }\n  if (fired(event, 'fingerprint_suppressed')) return { action: 'verify', event }\n\n  return { action: 'allow', event }\n}\n\nexport async function signUp(email: string, requestId: unknown) {\n  const { action, event } = await signUpDecision(requestId)\n  if (action === 'refuse') return { error: 'refused' }\n\n  \u002F\u002F Keep the device with the account, and hold it for review or verification.\n  return accounts.create({ email, status: action, visitorId: event?.visitor_id, signupRequestId: event?.request_id })\n}",{"label":62,"lang":63,"code":75},"from fingerly_server import fired, verified_event\n\n\ndef sign_up_decision(request_id):\n    event = verified_event(request_id, \"signup\")\n    if event is None:\n        return \"verify\", None\n\n    existing = accounts.count_by_visitor(event.visitor_id, days=30)\n\n    if existing >= 3:\n        return \"refuse\", event\n    if fired(event, \"device_farm\", \"bot\", \"android_emulator\", \"ios_simulator\", \"cloned_app\"):\n        return \"review\", event\n    if existing >= 1 or event.suspect_level != \"low\":\n        return \"verify\", event\n    if fired(event, \"fingerprint_suppressed\"):\n        return \"verify\", event\n\n    return \"allow\", event\n\n\ndef sign_up(email, request_id):\n    action, event = sign_up_decision(request_id)\n    if action == \"refuse\":\n        return {\"error\": \"refused\"}\n\n    # Keep the device with the account, and hold it for review or verification.\n    return accounts.create(\n        email=email,\n        status=action,\n        visitor_id=event.visitor_id if event else None,\n        signup_request_id=event.request_id if event else None,\n    )",{"type":14,"level":15,"text":77,"id":78},"A starting policy","a-starting-policy",{"type":19,"columns":80,"rows":83},[81,82],"Situation","Action",[84,87,90,93,96],[85,86],"The device created 3 or more accounts in 30 days","Refuse, or create the account without its free benefits.",[88,89],"`device_farm`, `bot`, `android_emulator`, `ios_simulator` or `cloned_app` fired","Create the account on hold for review.",[91,92],"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.",[94,95],"`fingerprint_suppressed` fired, or no usable identification","Ask for verification. The device cannot be counted.",[97,98],"Otherwise","Allow.",{"type":11,"text":100},"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.",{"type":14,"level":15,"text":102,"id":103},"Signals that matter here","signals-that-matter-here",{"type":19,"columns":105,"rows":108},[106,107],"Group","Why it matters for sign-ups",[109,112,115,118,121],[110,111],"`device_farm`","Devices that look mass-provisioned or freshly reset, or share traits with many devices at once.",[113,114],"`bot`","Sign-up forms filled by scripts.",[116,117],"`android_emulator`, `ios_simulator`, `virtual_machine`","Accounts created in bulk from virtual devices.",[119,120],"`cloned_app`","Several copies of your app on one phone, one per account.",[122,123],"`incognito_mode`, `privacy_settings`","Weak on their own: many real customers browse privately. Useful in combination.",{"type":14,"level":15,"text":125,"id":126},"Link existing accounts","link-existing-accounts",{"type":11,"text":128},"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.",{"type":130,"tone":131,"text":132},"callout","note","A visitor ID identifies a device, not a person. Households, shared computers and public terminals put several real people behind one visitor ID. Prefer verification and review to outright refusal.",{"type":14,"level":15,"text":134,"id":135},"Roll it out","roll-it-out",{"type":137,"items":138},"list",[139,140,141],"**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](\u002Fdocs\u002Frisk-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.",{"type":143,"columns":15,"cards":144},"cards",[145,150],{"title":146,"text":147,"href":148,"icon":149},"Promotion abuse","Stop sign-up bonuses and trials being claimed twice.","\u002Fdocs\u002Fuse-cases\u002Fpromotion-abuse","gift",{"title":151,"text":152,"href":153,"icon":154},"Visitor identification","How stable a visitor ID is.","\u002Fdocs\u002Fvisitor-identification","fingerprint",[156,157,158,159,160,161,162,163],{"id":17,"text":16,"level":15},{"id":35,"text":34,"level":15},{"id":53,"text":52,"level":15},{"id":69,"text":68,"level":15},{"id":78,"text":77,"level":15},{"id":103,"text":102,"level":15},{"id":126,"text":125,"level":15},{"id":135,"text":134,"level":15},"2026-09-17T16:58:12.000Z",1789667797514]