[{"data":1,"prerenderedAt":151},["ShallowReactive",2],{"doc:\u002Fdocs\u002Fuse-cases\u002Fcredential-stuffing":3},{"page":4,"toc":142,"updated":150},{"path":5,"title":6,"seoTitle":7,"description":8,"blocks":9},"\u002Fdocs\u002Fuse-cases\u002Fcredential-stuffing","Credential stuffing","Stop Credential Stuffing Attacks on Your Login","Stop scripts that replay leaked passwords against your login: refuse automation, and limit failed attempts and accounts per device instead of per address.",[10,13,15,20,34,37,52,55,57,66,68,71,73,79,82,105,108,115,119,122,125,130],{"type":11,"text":12},"p","Credential stuffing replays email and password pairs leaked from other sites against your login, hoping some customers reused them. It is automated, fast, and spread across many addresses so per-address rate limits never trigger. It succeeds quietly: the attacker ends up with a list of working logins to take over later.",{"type":11,"text":14},"Device identification changes what you can count. Addresses rotate for free; the devices and scripts behind them are far fewer.",{"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],[28,29,30],"Every login attempt, before the password is checked","`login`","The attack is the attempts themselves, successful or not.",[32,29,33],"Login endpoints of your API and apps","Scripts go wherever the form is weakest.",{"type":16,"level":17,"text":35,"id":36},"Identify in the client","identify-in-the-client",{"type":38,"samples":39},"code",[40,44,48],{"label":41,"lang":42,"code":43},"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":45,"lang":46,"code":47},"Swift","swift","let requestId = try? await fingerly.identify(tag: \"login\").requestId\ntry await api.signIn(email: email, password: password, requestId: requestId)",{"label":49,"lang":50,"code":51},"Kotlin","kotlin","val requestId = runCatching { fingerly.identify(tag = \"login\").requestId }.getOrNull()\napi.signIn(email, password, requestId)",{"type":16,"level":17,"text":53,"id":54},"Read the event on your server","read-the-event-on-your-server",{"type":11,"text":56},"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":38,"samples":58},[59,62],{"label":60,"lang":42,"code":61},"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":63,"lang":64,"code":65},"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":67},"`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":69,"id":70},"Decide","decide",{"type":11,"text":72},"Decide before you check the password, so a script learns nothing from attempts you refuse. Count failures and distinct accounts per visitor in a store with expiring counters.",{"type":38,"samples":74},[75,77],{"label":60,"lang":42,"code":76},"import { fired, verifiedEvent } from '.\u002Ffingerly.server'\n\nexport async function beforePasswordCheck(email: string, requestId: unknown) {\n  const event = await verifiedEvent(requestId, 'login')\n\n  \u002F\u002F A script calling your login endpoint directly never ran the SDK.\n  if (!event) return { action: 'captcha' }\n\n  if (fired(event, 'bot')) return { action: 'refuse', event }\n\n  const failures = await counters.get('login-failures:' + event.visitor_id)        \u002F\u002F last 15 minutes\n  const accounts = await counters.distinct('login-accounts:' + event.visitor_id)   \u002F\u002F last hour\n  await counters.addDistinct('login-accounts:' + event.visitor_id, email, { ttl: '1h' })\n\n  if (failures >= 10 || accounts >= 5) return { action: 'refuse', event }\n  if (failures >= 3 || event.suspect_level !== 'low') return { action: 'captcha', event }\n  if (fired(event, 'fingerprint_suppressed', 'datacenter_proxy', 'tor')) return { action: 'captcha', event }\n\n  return { action: 'check-password', event }\n}\n\nexport async function onWrongPassword(visitorId: string) {\n  await counters.increment('login-failures:' + visitorId, { ttl: '15m' })\n}",{"label":63,"lang":64,"code":78},"from fingerly_server import fired, verified_event\n\n\ndef before_password_check(email, request_id):\n    event = verified_event(request_id, \"login\")\n\n    # A script calling your login endpoint directly never ran the SDK.\n    if event is None:\n        return \"captcha\", None\n\n    if fired(event, \"bot\"):\n        return \"refuse\", event\n\n    failures = counters.get(f\"login-failures:{event.visitor_id}\")        # last 15 minutes\n    accounts = counters.distinct(f\"login-accounts:{event.visitor_id}\")   # last hour\n    counters.add_distinct(f\"login-accounts:{event.visitor_id}\", email, ttl=\"1h\")\n\n    if failures >= 10 or accounts >= 5:\n        return \"refuse\", event\n    if failures >= 3 or event.suspect_level != \"low\":\n        return \"captcha\", event\n    if fired(event, \"fingerprint_suppressed\", \"datacenter_proxy\", \"tor\"):\n        return \"captcha\", event\n\n    return \"check-password\", event\n\n\ndef on_wrong_password(visitor_id):\n    counters.increment(f\"login-failures:{visitor_id}\", ttl=\"15m\")",{"type":16,"level":17,"text":80,"id":81},"A starting policy","a-starting-policy",{"type":21,"columns":83,"rows":86},[84,85],"Situation","Action",[87,90,93,96,99,102],[88,89],"No usable identification","Show a CAPTCHA before checking the password.",[91,92],"`bot` fired","Refuse.",[94,95],"10 or more failed attempts, or 5 or more different accounts, from one visitor","Refuse for the rest of the window.",[97,98],"3 or more failed attempts, a level above `low`, or an unscored request","Show a CAPTCHA.",[100,101],"`fingerprint_suppressed`, `datacenter_proxy` or `tor` fired","Show a CAPTCHA. These visitors cannot be counted reliably, or rarely log in this way.",[103,104],"Otherwise","Check the password.",{"type":16,"level":17,"text":106,"id":107},"Why these rules","why-these-rules",{"type":109,"items":110},"list",[111,112,113,114],"**The request ID is required.** Attack tools post straight to your login endpoint. Requiring a fresh, unused request ID tagged `login` means every attempt has to run the SDK, and the one-time check stops one identification being reused for thousands of attempts.","**Count per visitor, not per address.** Residential proxies give each attempt a new address. The visitor ID stays with the device.","**Count accounts, not only failures.** A real customer mistypes their own password. One device trying many different accounts is almost never a customer.","**Keep your address limits.** Device limits and address limits catch different attacks. Use both.",{"type":116,"tone":117,"text":118},"callout","warning","Answer refused attempts the same way you answer a wrong password, and with the same timing, so the script cannot tell which credentials are valid.",{"type":116,"tone":120,"text":121},"tip","Watch `identification.refused` [webhooks](\u002Fdocs\u002Fwebhooks) during an attack. A burst of `rate_limited` means the attack is reaching Fingerly faster than your organization's [rate limit](\u002Fdocs\u002Frate-limits).",{"type":16,"level":17,"text":123,"id":124},"Roll it out","roll-it-out",{"type":109,"items":126},[127,128,129],"**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":131,"columns":17,"cards":132},"cards",[133,138],{"title":134,"text":135,"href":136,"icon":137},"Account takeover","What to do once the right password arrives.","\u002Fdocs\u002Fuse-cases\u002Faccount-takeover","lock",{"title":139,"text":140,"href":141,"icon":109},"Signals reference","Every signal group and its default weight.","\u002Fdocs\u002Fsignals",[143,144,145,146,147,148,149],{"id":19,"text":18,"level":17},{"id":36,"text":35,"level":17},{"id":54,"text":53,"level":17},{"id":70,"text":69,"level":17},{"id":81,"text":80,"level":17},{"id":107,"text":106,"level":17},{"id":124,"text":123,"level":17},"2026-09-17T16:58:12.000Z",1789667797514]