# Credential stuffing

> Stop scripts that replay leaked passwords against your login: refuse automation, and limit failed attempts and accounts per device instead of per address.

Last updated: 2026-09-17

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.

Device identification changes what you can count. Addresses rotate for free; the devices and scripts behind them are far fewer.

## Where to identify

| Moment | Tag | Why |
| --- | --- | --- |
| Every login attempt, before the password is checked | `login` | The attack is the attempts themselves, successful or not. |
| Login endpoints of your API and apps | `login` | Scripts go wherever the form is weakest. |

## Identify in the client

```ts JavaScript
import { load } from '@fingerly/web-js'

const fingerly = await load({ apiKey: 'fly_pk_us_production_…' })

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 Swift
let requestId = try? await fingerly.identify(tag: "login").requestId
try await api.signIn(email: email, password: password, requestId: requestId)
```

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

## Read the event on your server

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.

```ts Node.js
// fingerly.server.ts
import { load, FingerlyAPIError } from '@fingerly/node'

const fingerly = load({ secretKey: process.env.FINGERLY_SECRET_KEY! })
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))

async function readEvent(requestId: string) {
  for (let attempt = 1; ; attempt++) {
    try {
      return await fingerly.events.get(requestId)
    } catch (error) {
      const status = error instanceof FingerlyAPIError ? error.status : undefined
      if (status === 404 && attempt < 4) await sleep(attempt * 250)   // not readable yet
      else if (status === 404 || status === 422) return null
      else throw error
    }
  }
}

/**
 * The stored event behind a request ID, or null when there is nothing to
 * trust: no ID, an unknown ID, another action's tag, too old, or used before.
 */
export async function verifiedEvent(requestId: unknown, tag: string, maxAgeMs = 2 * 60 * 1000) {
  if (typeof requestId !== 'string' || requestId === '') return null

  const event = await readEvent(requestId)
  if (!event || event.tag !== tag) return null
  if (Date.now() - Date.parse(event.occurred_at) > maxAgeMs) return null
  if (!(await usedRequestIds.add(event.request_id))) return null   // your store: true only the first time

  return event
}

/** Whether any of these signal groups fired. */
export const fired = (event: { triggers: Array<{ signal: string }> }, ...groups: string[]) =>
  event.triggers.some((trigger) => groups.includes(trigger.signal))
```

```python Python
# fingerly_server.py
import os
import time
from datetime import datetime, timedelta, timezone
from fingerly import Fingerly, FingerlyAPIError

fingerly = Fingerly(secret_key=os.environ["FINGERLY_SECRET_KEY"])

def _read_event(request_id):
    for attempt in range(1, 5):
        try:
            return fingerly.events.get(request_id)
        except FingerlyAPIError as error:
            if error.status == 404 and attempt < 4:
                time.sleep(attempt * 0.25)  # not readable yet
            elif error.status in (404, 422):
                return None
            else:
                raise

def verified_event(request_id, tag, max_age=timedelta(minutes=2)):
    """The stored event behind a request ID, or None when there is nothing to trust."""
    if not isinstance(request_id, str) or not request_id:
        return None

    event = _read_event(request_id)
    if event is None or event.tag != tag:
        return None
    if datetime.now(timezone.utc) - event.occurred_at > max_age:
        return None
    if not used_request_ids.add(event.request_id):  # your store: True only the first time
        return None
    return event

def fired(event, *groups):
    """Whether any of these signal groups fired."""
    return any(trigger.signal in groups for trigger in event.triggers)
```

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

## Decide

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.

```ts Node.js
import { fired, verifiedEvent } from './fingerly.server'

export async function beforePasswordCheck(email: string, requestId: unknown) {
  const event = await verifiedEvent(requestId, 'login')

  // A script calling your login endpoint directly never ran the SDK.
  if (!event) return { action: 'captcha' }

  if (fired(event, 'bot')) return { action: 'refuse', event }

  const failures = await counters.get('login-failures:' + event.visitor_id)        // last 15 minutes
  const accounts = await counters.distinct('login-accounts:' + event.visitor_id)   // last hour
  await counters.addDistinct('login-accounts:' + event.visitor_id, email, { ttl: '1h' })

  if (failures >= 10 || accounts >= 5) return { action: 'refuse', event }
  if (failures >= 3 || event.suspect_level !== 'low') return { action: 'captcha', event }
  if (fired(event, 'fingerprint_suppressed', 'datacenter_proxy', 'tor')) return { action: 'captcha', event }

  return { action: 'check-password', event }
}

export async function onWrongPassword(visitorId: string) {
  await counters.increment('login-failures:' + visitorId, { ttl: '15m' })
}
```

```python Python
from fingerly_server import fired, verified_event

def before_password_check(email, request_id):
    event = verified_event(request_id, "login")

    # A script calling your login endpoint directly never ran the SDK.
    if event is None:
        return "captcha", None

    if fired(event, "bot"):
        return "refuse", event

    failures = counters.get(f"login-failures:{event.visitor_id}")        # last 15 minutes
    accounts = counters.distinct(f"login-accounts:{event.visitor_id}")   # last hour
    counters.add_distinct(f"login-accounts:{event.visitor_id}", email, ttl="1h")

    if failures >= 10 or accounts >= 5:
        return "refuse", event
    if failures >= 3 or event.suspect_level != "low":
        return "captcha", event
    if fired(event, "fingerprint_suppressed", "datacenter_proxy", "tor"):
        return "captcha", event

    return "check-password", event

def on_wrong_password(visitor_id):
    counters.increment(f"login-failures:{visitor_id}", ttl="15m")
```

## A starting policy

| Situation | Action |
| --- | --- |
| No usable identification | Show a CAPTCHA before checking the password. |
| `bot` fired | Refuse. |
| 10 or more failed attempts, or 5 or more different accounts, from one visitor | Refuse for the rest of the window. |
| 3 or more failed attempts, a level above `low`, or an unscored request | Show a CAPTCHA. |
| `fingerprint_suppressed`, `datacenter_proxy` or `tor` fired | Show a CAPTCHA. These visitors cannot be counted reliably, or rarely log in this way. |
| Otherwise | Check the password. |

## Why these rules

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

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

> **Tip:** Watch `identification.refused` [webhooks](https://docs.fingerly.io/docs/webhooks) during an attack. A burst of `rate_limited` means the attack is reaching Fingerly faster than your organization's [rate limit](https://docs.fingerly.io/docs/rate-limits).

## Roll it out

- **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](https://docs.fingerly.io/docs/risk-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.

- [Account takeover](https://docs.fingerly.io/docs/use-cases/account-takeover): What to do once the right password arrives.
- [Signals reference](https://docs.fingerly.io/docs/signals): Every signal group and its default weight.
