# 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.

Last updated: 2026-11-12

**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

| Stage | Question | What decides it |
| --- | --- | --- |
| Before the password check | Is this a script, or a device attacking many accounts? | `bot`, attempts and accounts per visitor, and anonymised networks. |
| After the password check | Has this account used this device before? | Known devices, the level, and the signals. |

## Protect the login

### 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.

```ts JavaScript
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 }) })
}
```

```swift iOS
let requestId = try? await fingerly.identify(tag: "login").requestId
try await api.signIn(email: email, password: password, requestId: requestId)
```

```kotlin Android
val requestId = runCatching { fingerly.identify(tag = "login").requestId }.getOrNull()
api.signIn(email, password, requestId)
```

### Step 2: Stop credential stuffing before the password is checked

The server code below uses two helpers from the [use-case recipes](https://docs.fingerly.io/docs/use-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](https://docs.fingerly.io/docs/signals) fired.

```ts 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.

### 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.

```ts 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 group | Why it matters |
| --- | --- |
| `bot` | Stuffing tools automate logins. `visitor_kind` on the event also says `automation` or `ai_bot`. |
| `tor`, `datacenter_proxy`, `residential_proxy`, `vpn` | Attackers hide where they are and rotate addresses to get past per-address limits. |
| `location_spoofing` | The device pretends to be somewhere else, often near the victim. |
| `browser_tampering`, `virtual_machine` | Takeover tooling disguises the browser. |
| `android_emulator`, `ios_simulator`, `rooted_device`, `jailbroken_device`, `frida_detected` | In apps, takeovers run from emulators and modified devices. |

> **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.

> **Tip:** Watch the `identification.refused` [webhook](https://docs.fingerly.io/docs/webhooks) during an attack. A burst of `rate_limited` means traffic is reaching Fingerly faster than a [rate limit](https://docs.fingerly.io/docs/rate-limits) allows. Its `scope` says which one.

## 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.

- [Account takeover recipe](https://docs.fingerly.io/docs/use-cases/account-takeover): The full server helper and policy.
- [Credential stuffing recipe](https://docs.fingerly.io/docs/use-cases/credential-stuffing): The full counters and thresholds.
- [Bot, VPN and Tor detection](https://docs.fingerly.io/docs/bot-and-vpn-detection): The fields on the event.
- [Server-side verification](https://docs.fingerly.io/docs/server-side-verification): The four checks before you trust a result.
