In an account takeover, someone other than the customer gets into their account, usually with a password stolen from another site, phished, or reset through a hijacked email address. To your login form the attacker looks like the customer: the right email and the right password. The device is what differs.
This recipe gives each account a list of devices it is known to use. Known devices get through with little friction, and the rest are challenged in proportion to how suspicious they look.
Where to identify
| Moment | Tag | Why |
|---|---|---|
| Login | login | Where a stolen password is first used. |
| Password reset request | password-reset | Many takeovers start with a reset from a device the account has never used. |
| Changing the email, phone number or second factor | account-change | Attackers lock the owner out before they act. |
| Payout, withdrawal or new payee | payout:<id> | Where a takeover becomes a loss. |
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
Check the password first, as you do today. Only for a correct password, decide what the device means.
import { verifiedEvent } from './fingerly.server'
export async function afterPasswordCheck(accountId: string, requestId: unknown) {
const event = await verifiedEvent(requestId, 'login')
if (!event) return { action: 'second-factor' }
const known = await knownDevices.has(accountId, event.visitor_id)
if (event.suspect_level === 'high') return { action: known ? 'second-factor' : 'block', event }
if (known) return { action: 'allow', event }
if (event.suspect_level === 'low') return { action: 'allow-and-notify', event }
return { action: 'second-factor', event } // medium, or not scored
}
// Once the customer is fully signed in, including any second factor:
export async function onSignedIn(accountId: string, visitorId: string) {
await knownDevices.add(accountId, visitorId)
}
A starting policy
| Level | Known device | New device |
|---|---|---|
low | Allow. | Allow, and tell the customer about a sign-in from a new device. |
medium, or not scored | Allow. | Ask for a second factor. |
high | Ask for a second factor. | Refuse the attempt without saying why, and tell the customer. |
| No usable identification | Ask for a second factor. | Ask for a second factor. |
Use the same table for password resets, account changes and payouts, with their own tags. For payouts, consider treating medium on a new device as high.
Known devices
- Add a device only after a sign-in that fully succeeded, including any second factor. Otherwise an attacker's failed attempt would make their device known.
- Keep
last_seenwith each device, and forget devices that have not been seen for a few months. A visitor identity Fingerly has not seen for 180 days is issued a new visitor ID anyway. - A device whose browser hides almost everything gets a new visitor ID every time, so it is never known. It raises
fingerprint_suppressed, which alone scoresmediumwith the default weights, so these customers are asked for a second factor. - A known device lowers friction. It is never a reason to skip the password.
Signals that matter here
| Group | Why it matters for takeover |
|---|---|
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. |
bot, browser_tampering, virtual_machine | Takeover tooling automates logins and disguises the browser. |
android_emulator, ios_simulator, rooted_device, jailbroken_device, frida_detected | In apps, takeovers run from emulators and modified devices. |
active_call | On a payout, a customer on a phone call may be being coached by a scammer. |
To make one of these decisive, read it from event.triggers with fired(event, …), or raise its weight in risk weights.
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.