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

Last updated: 2026-09-17

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

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

Check the password first, as you do today. Only for a correct password, decide what the device means.

```ts Node.js
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)
}
```

```python Python
from fingerly_server import verified_event

def after_password_check(account_id, request_id):
    event = verified_event(request_id, "login")
    if event is None:
        return "second-factor", None

    known = known_devices.has(account_id, event.visitor_id)

    if event.suspect_level == "high":
        return ("second-factor" if known else "block"), event
    if known:
        return "allow", event
    if event.suspect_level == "low":
        return "allow-and-notify", event
    return "second-factor", event  # medium, or not scored

# Once the customer is fully signed in, including any second factor:
def on_signed_in(account_id, visitor_id):
    known_devices.add(account_id, visitor_id)
```

## 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_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

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

- [Credential stuffing](https://docs.fingerly.io/docs/use-cases/credential-stuffing): Stop the scripts before they find a password.
- [Visitor identification](https://docs.fingerly.io/docs/visitor-identification): What a visitor ID is, and when it changes.
