[{"data":1,"prerenderedAt":188},["ShallowReactive",2],{"doc:\u002Fdocs\u002Fprevent-account-takeover":3},{"page":4,"toc":171,"updated":187},{"path":5,"title":6,"seoTitle":7,"description":8,"blocks":9},"\u002Fdocs\u002Fprevent-account-takeover","Prevent account takeover and credential stuffing","Prevent Account Takeover and Credential Stuffing","Protect logins from account takeover and credential stuffing: refuse automation, count failed attempts per device, and challenge new devices after the password.",[10,13,15,20,35,38,89,92,112,116,119,122,126,128,131,133,136,138,141,143,146,148],{"type":11,"text":12},"p","**Credential stuffing** replays email and password pairs leaked elsewhere against your login. **Account takeover** is what follows when one works: someone other than the customer is signed in, with the right password. To your form the attacker looks like the customer. The device and the behaviour are what differ.",{"type":11,"text":14},"This guide protects a login in two stages with Fingerly: before the password is checked, stop scripts and count attempts per device, and after it is checked, let known devices through and challenge new ones.",{"type":16,"level":17,"text":18,"id":19},"heading",2,"Where each stage fits","where-each-stage-fits",{"type":21,"columns":22,"rows":26},"table",[23,24,25],"Stage","Question","What decides it",[27,31],[28,29,30],"Before the password check","Is this a script, or a device attacking many accounts?","`bot`, attempts and accounts per visitor, and anonymised networks.",[32,33,34],"After the password check","Has this account used this device before?","Known devices, the level, and the signals.",{"type":16,"level":17,"text":36,"id":37},"Protect the login","protect-the-login",{"type":39,"steps":40},"steps",[41,61,74],{"title":42,"blocks":43},"Identify on every login attempt",[44,46],{"type":11,"text":45},"Identify before the password is checked, successful or not. A script posting straight to your login endpoint never ran the SDK, so it has no request ID.",{"type":47,"samples":48},"code",[49,53,57],{"label":50,"lang":51,"code":52},"JavaScript","ts","import { load } from '@fingerly\u002Fweb-js'\n\n\u002F\u002F consent: 'granted' only once you have consent (or another lawful basis)\nconst fingerly = await load({ apiKey: 'fly_pk_development_…', consent: 'granted' })\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},"iOS","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},"Android","kotlin","val requestId = runCatching { fingerly.identify(tag = \"login\").requestId }.getOrNull()\napi.signIn(email, password, requestId)",{"title":62,"blocks":63},"Stop credential stuffing before the password is checked",[64,66,72],{"type":11,"text":65},"The server code below uses two helpers from the [use-case recipes](\u002Fdocs\u002Fuse-cases): `verifiedEvent(requestId, tag)`, which reads the stored event with a secret key and returns it only when it belongs to the action, is recent and has not been used before, and `fired(event, ...groups)`, which says whether a [signal group](\u002Fdocs\u002Fsignals) fired.",{"type":47,"samples":67},[68],{"label":69,"lang":51,"code":70,"check":71},"Node.js","import { fired, verifiedEvent } from '.\u002Ffingerly.server'\n\nexport async function beforePasswordCheck(email: string, requestId: unknown) {\n  const event = await verifiedEvent(requestId, 'login')\n  if (!event) return 'captcha' \u002F\u002F no SDK run, no evidence\n\n  if (fired(event, 'bot')) return 'refuse'\n\n  const failures = await counters.get('login-failures:' + event.visitor_id) \u002F\u002F last 15 minutes\n  await counters.addDistinct('login-accounts:' + event.visitor_id, email, { ttl: '1h' })\n  const accounts = await counters.distinct('login-accounts:' + event.visitor_id)\n\n  if (failures >= 10 || accounts >= 5) return 'refuse'\n  if (failures >= 3 || event.suspect_level !== 'low') return 'captcha'\n  return 'check-password'\n}",false,{"type":11,"text":73},"`counters` is your own store with expiring counters. Count per visitor and not per address, because residential proxies give each attempt a new address and the visitor ID stays with the device. Keep your address limits as well: device and address limits catch different attacks.",{"title":75,"blocks":76},"Challenge new devices after the password",[77,79,83],{"type":11,"text":78},"Only for a correct password, decide what the device means. A known device gets through with little friction, and a new one is challenged in proportion to how suspicious it looks.",{"type":47,"samples":80},[81],{"label":69,"lang":51,"code":82,"check":71},"export async function afterPasswordCheck(accountId: string, requestId: unknown) {\n  const event = await verifiedEvent(requestId, 'login')\n  if (!event) return 'second-factor'\n\n  const known = await knownDevices.has(accountId, event.visitor_id)\n\n  if (event.suspect_level === 'high') return known ? 'second-factor' : 'block'\n  if (known) return 'allow'\n  return event.suspect_level === 'low' ? 'allow-and-notify' : 'second-factor'\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}",{"type":84,"items":85},"list",[86,87,88],"Add a device to the known list only after a sign-in that fully succeeded, so an attacker's failed attempt never makes their device known.","A known device lowers friction. It is never a reason to skip the password.","Use the same check for password resets, changes of email or phone, and payouts, each with its own tag.",{"type":16,"level":17,"text":90,"id":91},"Signals that matter at a login","signals-that-matter-at-a-login",{"type":21,"columns":93,"rows":96},[94,95],"Signal group","Why it matters",[97,100,103,106,109],[98,99],"`bot`","Stuffing tools automate logins. `visitor_kind` on the event also says `automation` or `ai_bot`.",[101,102],"`tor`, `datacenter_proxy`, `residential_proxy`, `vpn`","Attackers hide where they are and rotate addresses to get past per-address limits.",[104,105],"`location_spoofing`","The device pretends to be somewhere else, often near the victim.",[107,108],"`browser_tampering`, `virtual_machine`","Takeover tooling disguises the browser.",[110,111],"`android_emulator`, `ios_simulator`, `rooted_device`, `jailbroken_device`, `frida_detected`","In apps, takeovers run from emulators and modified devices.",{"type":113,"tone":114,"text":115},"callout","warning","Answer refused attempts the same way you answer a wrong password, with the same timing, so a script cannot tell which credentials are valid.",{"type":113,"tone":117,"text":118},"tip","Watch the `identification.refused` [webhook](\u002Fdocs\u002Fwebhooks) during an attack. A burst of `rate_limited` means traffic is reaching Fingerly faster than a [rate limit](\u002Fdocs\u002Frate-limits) allows. Its `scope` says which one.",{"type":16,"level":17,"text":120,"id":121},"Common questions","common-questions",{"type":16,"level":123,"text":124,"id":125},3,"How do I detect credential stuffing?","how-do-i-detect-credential-stuffing",{"type":11,"text":127},"Require a recent request ID from an identification tagged `login` on every attempt, refuse the `bot` group, and count failed attempts and distinct accounts per visitor ID. One device trying many accounts is almost never a customer.",{"type":16,"level":123,"text":129,"id":130},"How do I prevent account takeover without adding friction for everyone?","how-do-i-prevent-account-takeover-without-adding-friction-for-everyone",{"type":11,"text":132},"Keep a list of devices each account has used, and let known devices through. Challenge a new device in proportion to its level: allow and notify on `low`, ask for a second factor on `medium`, and refuse or hold on `high`.",{"type":16,"level":123,"text":134,"id":135},"Why count per device and not per IP address?","why-count-per-device-and-not-per-ip-address",{"type":11,"text":137},"Addresses rotate for free through proxies and VPNs, while the devices and scripts behind them are far fewer. Count per visitor ID, and keep your per-address limits too.",{"type":16,"level":123,"text":139,"id":140},"Does it replace a CAPTCHA or two-factor authentication?","does-it-replace-a-captcha-or-two-factor-authentication",{"type":11,"text":142},"No. It decides when to use them. The score picks who gets a CAPTCHA or a second factor, so most customers see neither. Keep authenticating people the way you do now.",{"type":16,"level":123,"text":144,"id":145},"What if the visitor ID is missing?","what-if-the-visitor-id-is-missing",{"type":11,"text":147},"Treat it as missing evidence. A script that never ran the SDK has no request ID, so show a CAPTCHA before checking the password, and ask for a second factor after it.",{"type":149,"columns":17,"cards":150},"cards",[151,156,161,166],{"title":152,"text":153,"href":154,"icon":155},"Account takeover recipe","The full server helper and policy.","\u002Fdocs\u002Fuse-cases\u002Faccount-takeover","lock",{"title":157,"text":158,"href":159,"icon":160},"Credential stuffing recipe","The full counters and thresholds.","\u002Fdocs\u002Fuse-cases\u002Fcredential-stuffing","bot",{"title":162,"text":163,"href":164,"icon":165},"Bot, VPN and Tor detection","The fields on the event.","\u002Fdocs\u002Fbot-and-vpn-detection","shield",{"title":167,"text":168,"href":169,"icon":170},"Server-side verification","The four checks before you trust a result.","\u002Fdocs\u002Fserver-side-verification","server",[172,173,174,176,178,180,181,182,183,184,185,186],{"id":19,"text":18,"level":17},{"id":37,"text":36,"level":17},{"id":175,"text":42,"level":123},"step-identify-on-every-login-attempt",{"id":177,"text":62,"level":123},"step-stop-credential-stuffing-before-the-password-is-checked",{"id":179,"text":75,"level":123},"step-challenge-new-devices-after-the-password",{"id":91,"text":90,"level":17},{"id":121,"text":120,"level":17},{"id":125,"text":124,"level":123},{"id":130,"text":129,"level":123},{"id":135,"text":134,"level":123},{"id":140,"text":139,"level":123},{"id":145,"text":144,"level":123},"2026-11-12T11:59:51.000Z",1794493376948]