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