Guides

Account takeover

Remember the devices each account uses, let them through with little friction, and challenge logins, resets and payouts from new or suspicious devices.

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

MomentTagWhy
LoginloginWhere a stolen password is first used.
Password reset requestpassword-resetMany takeovers start with a reset from a device the account has never used.
Changing the email, phone number or second factoraccount-changeAttackers lock the owner out before they act.
Payout, withdrawal or new payeepayout:<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

LevelKnown deviceNew device
lowAllow.Allow, and tell the customer about a sign-in from a new device.
medium, or not scoredAllow.Ask for a second factor.
highAsk for a second factor.Refuse the attempt without saying why, and tell the customer.
No usable identificationAsk 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_seen with 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 scores medium with 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

GroupWhy it matters for takeover
tor, datacenter_proxy, residential_proxy, vpnAttackers hide where they are, and rotate addresses to get past per-address limits.
location_spoofingThe device pretends to be somewhere else, often near the victim.
bot, browser_tampering, virtual_machineTakeover tooling automates logins and disguises the browser.
android_emulator, ios_simulator, rooted_device, jailbroken_device, frida_detectedIn apps, takeovers run from emulators and modified devices.
active_callOn 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.