[{"data":1,"prerenderedAt":177},["ShallowReactive",2],{"doc:\u002Fdocs\u002Fuse-cases\u002Faccount-takeover":3},{"page":4,"toc":167,"updated":176},{"path":5,"title":6,"seoTitle":7,"description":8,"blocks":9},"\u002Fdocs\u002Fuse-cases\u002Faccount-takeover","Account takeover","Prevent Account Takeover with Device Intelligence","Remember the devices each account uses, let them through with little friction, and challenge logins, resets and payouts from new or suspicious devices.",[10,13,15,20,43,46,61,64,66,75,77,80,82,88,91,109,111,114,121,124,144,146,149,154],{"type":11,"text":12},"p","In an account takeover, someone other than the customer gets into their account, usually with a password stolen from another site, phished, or reset through a hijacked email address. To your login form the attacker looks like the customer: the right email and the right password. The device is what differs.",{"type":11,"text":14},"This recipe gives each account a list of devices it is known to use. Known devices get through with little friction, and the rest are challenged in proportion to how suspicious they look.",{"type":16,"level":17,"text":18,"id":19},"heading",2,"Where to identify","where-to-identify",{"type":21,"columns":22,"rows":26},"table",[23,24,25],"Moment","Tag","Why",[27,31,35,39],[28,29,30],"Login","`login`","Where a stolen password is first used.",[32,33,34],"Password reset request","`password-reset`","Many takeovers start with a reset from a device the account has never used.",[36,37,38],"Changing the email, phone number or second factor","`account-change`","Attackers lock the owner out before they act.",[40,41,42],"Payout, withdrawal or new payee","`payout:\u003Cid>`","Where a takeover becomes a loss.",{"type":16,"level":17,"text":44,"id":45},"Identify in the client","identify-in-the-client",{"type":47,"samples":48},"code",[49,53,57],{"label":50,"lang":51,"code":52},"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: 'login' }))\n  } catch {\n    \u002F\u002F Carry on: your server treats a missing request ID as missing evidence.\n  }\n  await fetch('\u002Fapi\u002Flogin', { method: 'POST', headers: { 'Content-Type': 'application\u002Fjson' }, body: JSON.stringify({ email, password, requestId }) })\n}",{"label":54,"lang":55,"code":56},"Swift","swift","let requestId = try? await fingerly.identify(tag: \"login\").requestId\ntry await api.signIn(email: email, password: password, requestId: requestId)",{"label":58,"lang":59,"code":60},"Kotlin","kotlin","val requestId = runCatching { fingerly.identify(tag = \"login\").requestId }.getOrNull()\napi.signIn(email, password, requestId)",{"type":16,"level":17,"text":62,"id":63},"Read the event on your server","read-the-event-on-your-server",{"type":11,"text":65},"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":47,"samples":67},[68,71],{"label":69,"lang":51,"code":70},"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":72,"lang":73,"code":74},"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":76},"`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":16,"level":17,"text":78,"id":79},"Decide","decide",{"type":11,"text":81},"Check the password first, as you do today. Only for a correct password, decide what the device means.",{"type":47,"samples":83},[84,86],{"label":69,"lang":51,"code":85},"import { verifiedEvent } from '.\u002Ffingerly.server'\n\nexport async function afterPasswordCheck(accountId: string, requestId: unknown) {\n  const event = await verifiedEvent(requestId, 'login')\n  if (!event) return { action: 'second-factor' }\n\n  const known = await knownDevices.has(accountId, event.visitor_id)\n\n  if (event.suspect_level === 'high') return { action: known ? 'second-factor' : 'block', event }\n  if (known) return { action: 'allow', event }\n  if (event.suspect_level === 'low') return { action: 'allow-and-notify', event }\n  return { action: 'second-factor', event }   \u002F\u002F medium, or not scored\n}\n\n\u002F\u002F Once the customer is fully signed in, including any second factor:\nexport async function onSignedIn(accountId: string, visitorId: string) {\n  await knownDevices.add(accountId, visitorId)\n}",{"label":72,"lang":73,"code":87},"from fingerly_server import verified_event\n\n\ndef after_password_check(account_id, request_id):\n    event = verified_event(request_id, \"login\")\n    if event is None:\n        return \"second-factor\", None\n\n    known = known_devices.has(account_id, event.visitor_id)\n\n    if event.suspect_level == \"high\":\n        return (\"second-factor\" if known else \"block\"), event\n    if known:\n        return \"allow\", event\n    if event.suspect_level == \"low\":\n        return \"allow-and-notify\", event\n    return \"second-factor\", event  # medium, or not scored\n\n\n# Once the customer is fully signed in, including any second factor:\ndef on_signed_in(account_id, visitor_id):\n    known_devices.add(account_id, visitor_id)",{"type":16,"level":17,"text":89,"id":90},"A starting policy","a-starting-policy",{"type":21,"columns":92,"rows":96},[93,94,95],"Level","Known device","New device",[97,101,104,107],[98,99,100],"`low`","Allow.","Allow, and tell the customer about a sign-in from a new device.",[102,99,103],"`medium`, or not scored","Ask for a second factor.",[105,103,106],"`high`","Refuse the attempt without saying why, and tell the customer.",[108,103,103],"No usable identification",{"type":11,"text":110},"Use the same table for password resets, account changes and payouts, with their own tags. For payouts, consider treating `medium` on a new device as `high`.",{"type":16,"level":17,"text":112,"id":113},"Known devices","known-devices",{"type":115,"items":116},"list",[117,118,119,120],"Add a device only after a sign-in that fully succeeded, including any second factor. Otherwise an attacker's failed attempt would make their device known.","Keep `last_seen` with each device, and forget devices that have not been seen for a few months. A visitor identity Fingerly has not seen for 180 days is issued a new visitor ID anyway.","A device whose browser hides almost everything gets a new visitor ID every time, so it is never known. It raises `fingerprint_suppressed`, which alone scores `medium` with the default weights, so these customers are asked for a second factor.","A known device lowers friction. It is never a reason to skip the password.",{"type":16,"level":17,"text":122,"id":123},"Signals that matter here","signals-that-matter-here",{"type":21,"columns":125,"rows":128},[126,127],"Group","Why it matters for takeover",[129,132,135,138,141],[130,131],"`tor`, `datacenter_proxy`, `residential_proxy`, `vpn`","Attackers hide where they are, and rotate addresses to get past per-address limits.",[133,134],"`location_spoofing`","The device pretends to be somewhere else, often near the victim.",[136,137],"`bot`, `browser_tampering`, `virtual_machine`","Takeover tooling automates logins and disguises the browser.",[139,140],"`android_emulator`, `ios_simulator`, `rooted_device`, `jailbroken_device`, `frida_detected`","In apps, takeovers run from emulators and modified devices.",[142,143],"`active_call`","On a payout, a customer on a phone call may be being coached by a scammer.",{"type":11,"text":145},"To make one of these decisive, read it from `event.triggers` with `fired(event, …)`, or raise its weight in [risk weights](\u002Fdocs\u002Frisk-weights).",{"type":16,"level":17,"text":147,"id":148},"Roll it out","roll-it-out",{"type":115,"items":150},[151,152,153],"**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":155,"columns":17,"cards":156},"cards",[157,162],{"title":158,"text":159,"href":160,"icon":161},"Credential stuffing","Stop the scripts before they find a password.","\u002Fdocs\u002Fuse-cases\u002Fcredential-stuffing","bot",{"title":163,"text":164,"href":165,"icon":166},"Visitor identification","What a visitor ID is, and when it changes.","\u002Fdocs\u002Fvisitor-identification","fingerprint",[168,169,170,171,172,173,174,175],{"id":19,"text":18,"level":17},{"id":45,"text":44,"level":17},{"id":63,"text":62,"level":17},{"id":79,"text":78,"level":17},{"id":90,"text":89,"level":17},{"id":113,"text":112,"level":17},{"id":123,"text":122,"level":17},{"id":148,"text":147,"level":17},"2026-09-17T16:58:12.000Z",1789667797514]