Guides

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.

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.

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.

Where each stage fits

StageQuestionWhat decides it
Before the password checkIs this a script, or a device attacking many accounts?bot, attempts and accounts per visitor, and anonymised networks.
After the password checkHas this account used this device before?Known devices, the level, and the signals.

Protect the login

  1. Step 1: Identify on every login attempt

    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.

    import { load } from '@fingerly/web-js'
    
    // consent: 'granted' only once you have consent (or another lawful basis)
    const fingerly = await load({ apiKey: 'fly_pk_development_…', consent: 'granted' })
    
    async function submit() {
      let requestId: string | undefined
      try {
        ({ requestId } = await fingerly.identify({ tag: 'login' }))
      } catch {
        // Carry on: your server treats a missing request ID as missing evidence.
      }
      await fetch('/api/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email, password, requestId }) })
    }
    
  2. Step 2: Stop credential stuffing before the password is checked

    The server code below uses two helpers from the use-case recipes: 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 fired.

    Node.js
    import { fired, verifiedEvent } from './fingerly.server'
    
    export async function beforePasswordCheck(email: string, requestId: unknown) {
      const event = await verifiedEvent(requestId, 'login')
      if (!event) return 'captcha' // no SDK run, no evidence
    
      if (fired(event, 'bot')) return 'refuse'
    
      const failures = await counters.get('login-failures:' + event.visitor_id) // last 15 minutes
      await counters.addDistinct('login-accounts:' + event.visitor_id, email, { ttl: '1h' })
      const accounts = await counters.distinct('login-accounts:' + event.visitor_id)
    
      if (failures >= 10 || accounts >= 5) return 'refuse'
      if (failures >= 3 || event.suspect_level !== 'low') return 'captcha'
      return 'check-password'
    }
    

    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.

  3. Step 3: Challenge new devices after the password

    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.

    Node.js
    export async function afterPasswordCheck(accountId: string, requestId: unknown) {
      const event = await verifiedEvent(requestId, 'login')
      if (!event) return 'second-factor'
    
      const known = await knownDevices.has(accountId, event.visitor_id)
    
      if (event.suspect_level === 'high') return known ? 'second-factor' : 'block'
      if (known) return 'allow'
      return event.suspect_level === 'low' ? 'allow-and-notify' : 'second-factor'
    }
    
    // Once the customer is fully signed in, including any second factor:
    export async function onSignedIn(accountId: string, visitorId: string) {
      await knownDevices.add(accountId, visitorId)
    }
    
    • 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.

Signals that matter at a login

Signal groupWhy it matters
botStuffing tools automate logins. visitor_kind on the event also says automation or ai_bot.
tor, datacenter_proxy, residential_proxy, vpnAttackers hide where they are and rotate addresses to get past per-address limits.
location_spoofingThe device pretends to be somewhere else, often near the victim.
browser_tampering, virtual_machineTakeover tooling disguises the browser.
android_emulator, ios_simulator, rooted_device, jailbroken_device, frida_detectedIn apps, takeovers run from emulators and modified devices.

Common questions

How do I detect credential stuffing?

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.

How do I prevent account takeover without adding friction for everyone?

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.

Why count per device and not per IP address?

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.

Does it replace a CAPTCHA or two-factor authentication?

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.

What if the visitor ID is missing?

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.