Guides

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.

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

MomentTagWhy
Every login attempt, before the password is checkedloginThe attack is the attempts themselves, successful or not.
Login endpoints of your API and appsloginScripts go wherever the form is weakest.

Identify in the client

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 }) })
}

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.

// 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))

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.

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' })
}

A starting policy

SituationAction
No usable identificationShow a CAPTCHA before checking the password.
bot firedRefuse.
10 or more failed attempts, or 5 or more different accounts, from one visitorRefuse for the rest of the window.
3 or more failed attempts, a level above low, or an unscored requestShow a CAPTCHA.
fingerprint_suppressed, datacenter_proxy or tor firedShow a CAPTCHA. These visitors cannot be counted reliably, or rarely log in this way.
OtherwiseCheck 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.

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