# Detect multiple accounts with device fingerprinting

> Detect multi-accounting, duplicate accounts and ban evasion with a visitor ID: store it with every account, count accounts per device, and review the rest.

Last updated: 2026-11-12

**Multi-accounting** is one person running several accounts: to claim a sign-up bonus again, to start another free trial, to dodge a ban, or to vote and review as a crowd. Each account has a new email address, so email-based rules never see the pattern. The device usually stays the same.

Fingerly gives every device a **visitor ID**. Store it with each account, and accounts that share a device are one query away. This guide shows the whole path: identify at sign-up, store the visitor ID, count accounts per device, decide, and link accounts afterwards.

## What a visitor ID can and cannot tell you

- In a browser, the same device keeps its visitor ID after cookies and storage are cleared, because the server recognises it rather than reading a value back.
- In an iOS or Android app, the identity is a random install ID. On iOS it survives a reinstall, and on Android a reinstall gets a new visitor ID.
- A visitor ID is scoped to your organization, and a device not seen for 180 days is issued a new one.
- It identifies a device, not a person. A household sharing a tablet is several people behind one visitor ID, and one person on three devices is three.
- A client that gives away almost nothing comes back `identifiable: false` with a fresh visitor ID each time. Do not count those as one device, and do not count them as unique.

## Detect duplicate accounts

### Step 1: Identify at sign-up

Call `identify()` when the visitor submits the form, with a tag that names the action, and send the request ID with it.

```ts JavaScript
import { load } from '@fingerly/web-js'

// consent: 'granted' only once you have consent (or another lawful basis)
const fingerly = await load({ apiKey: 'fly_pk_development_…', consent: 'granted' })

async function submit() {
  let requestId: string | undefined
  try {
    ({ requestId } = await fingerly.identify({ tag: 'signup' }))
  } catch {
    // Carry on: your server treats a missing request ID as missing evidence.
  }
  await fetch('/api/signup', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email, requestId }) })
}
```

```swift iOS
let requestId = try? await fingerly.identify(tag: "signup").requestId
try await api.signUp(email: email, requestId: requestId)
```

```kotlin Android
val requestId = runCatching { fingerly.identify(tag = "signup").requestId }.getOrNull()
api.signUp(email, requestId)
```

### Step 2: Store the visitor ID with every account

Take the visitor ID from the stored event your server reads, never from what the client sends. Index it.

```text SQL
ALTER TABLE accounts ADD COLUMN visitor_id text;
ALTER TABLE accounts ADD COLUMN signup_request_id text;
CREATE INDEX accounts_visitor_id ON accounts (visitor_id);
```

Also store the visitor ID on logins, orders and redemptions. The value comes from joining it with what you already know.

### Step 3: Count accounts per device and decide

The server code below uses two helpers from the [use-case recipes](https://docs.fingerly.io/docs/use-cases): `verifiedEvent(requestId, tag)`, which reads the stored event with a secret key and returns it only when it belongs to the action, is recent and has not been used before, and `fired(event, ...groups)`, which says whether a [signal group](https://docs.fingerly.io/docs/signals) fired.

```ts Node.js
import { fired, verifiedEvent } from './fingerly.server'

export async function signUpDecision(requestId: unknown) {
  const event = await verifiedEvent(requestId, 'signup')
  if (!event) return { action: 'verify' as const }

  const existing = await accounts.countByVisitor(event.visitor_id, { days: 30 })
  const banned = await bannedVisitors.has(event.visitor_id)

  if (banned) return { action: 'review' as const, event }
  if (existing >= 3) return { action: 'refuse' as const, event }
  if (fired(event, 'device_farm', 'bot', 'android_emulator', 'ios_simulator', 'cloned_app')) {
    return { action: 'review' as const, event }
  }
  if (existing >= 1 || event.suspect_level !== 'low') return { action: 'verify' as const, event }
  return { action: 'allow' as const, event }
}
```

`accounts` and `bannedVisitors` are your own tables. Fingerly returns the visitor ID, and counting happens in your database.

### Step 4: Link accounts after the fact

When you find one bad account, its siblings are the other rows with the same visitor ID. To see what a device did in the last 30 days, list its events with a secret key.

```text SQL
SELECT visitor_id, count(*) AS accounts
FROM accounts
WHERE visitor_id IS NOT NULL
GROUP BY visitor_id
HAVING count(*) > 1
ORDER BY accounts DESC;
```

```ts Node.js
const history = await fingerly.events.list({
  visitor: 'X9pL2mRc7KvT4bQw8NdF',
  from: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000),
  limit: 200,
})
```

Events are readable for 30 days. Keep what you need for longer in your own records. See [data retention](https://docs.fingerly.io/docs/data-retention).

## Detect ban evasion

Ban evasion is multi-accounting with a motive: a banned user comes back under a new address. When you ban an account, add its visitor ID to a list of banned visitors. At sign-up, a visitor ID on that list is a strong reason to hold the new account for review.

- Hold for review rather than refuse. The ban may have followed a shared device, and the next person to use it did nothing.
- Do not tell the visitor which check caught them.
- On Android, a banned user who reinstalls gets a new visitor ID, so combine the device with other evidence such as a verified phone number.

## A starting policy

| Situation | Action |
| --- | --- |
| The visitor ID is on your banned list | Create the account on hold for review. |
| The device created 3 or more accounts in 30 days | Refuse, or create the account without its free benefits. |
| `device_farm`, `bot`, `android_emulator`, `ios_simulator` or `cloned_app` fired | Create the account on hold for review. |
| The device already has an account, the level is above `low`, or the request is unscored | Ask for verification, such as a phone number, before granting benefits. |
| No usable identification | Ask for verification. The device cannot be counted. |
| Otherwise | Allow. |

Pick the limit that fits your product. A family sharing a tablet may reasonably create two accounts, and a device creating twenty is not a household.

> **Tip:** Run it in observe mode first. Log the decision it would have made next to what happened for a week or two, then add friction a real customer can pass before you refuse anyone.

> **Note:** A visitor ID is evidence, not a verdict on a person. Prefer verification and review to outright refusal, and say in your terms that limits apply per device.

## Common questions

### How do I detect multiple accounts without cookies?

Identify at sign-up with a Fingerly SDK, read the stored event on your server, and store its `visitor_id` with the account. In a browser the same device keeps its visitor ID after cookies and storage are cleared. In an app the identity is a random install ID.

### Does a VPN or a new email address defeat it?

Neither changes the device, so neither by itself changes the visitor ID. The visitor ID comes from the device and not from the address or the email, and a VPN is reported separately as a signal.

### What about people who share a device?

They share a visitor ID, so a per-device limit has to leave room for a household. Start with a limit above one, and use verification and review to separate real customers from farmed accounts.

### Can I find all accounts that share a device?

Yes, if you stored the visitor ID with each account: group your accounts by it. Fingerly keeps events for 30 days and has no API for counting accounts, so the account-to-device record is yours.

### What if the device cannot be identified?

Some clients give away almost nothing and come back `identifiable: false`. Their `fingerprint_suppressed` signal fires, and they get a new visitor ID every time, so ask for verification instead of counting them.

- [Sign-up abuse recipe](https://docs.fingerly.io/docs/use-cases/sign-up-abuse): The full server helper and policy.
- [Visitor identification](https://docs.fingerly.io/docs/visitor-identification): How stable a visitor ID is.
- [Prevent promo abuse](https://docs.fingerly.io/docs/prevent-promo-abuse): One redemption per device.
- [Reading events](https://docs.fingerly.io/docs/reading-events): List what a device did.
