Detect account takeover at login with Node.js

Updated

At login, the browser identifies itself and gets an eventId. Your Node.js server reads that event with GET /v1/events/:eventId and your secret key, takes the visitorId from the event, and compares it with the visitor IDs you have stored for the account. A device the account has not used before gets a step-up check, such as an email code, before the session is created. The browser never reports its own visitor ID.

The flow

A stolen password works from any device. A device the account has never used is the cheapest sign that something is off, and your server can check it at login.

  • The browser calls identify on the login page and sends the eventId with the credentials.
  • Your server fetches the event with the secret key and reads visitorId from it.
  • Your server compares that visitor ID with the ones stored for the account.
  • Unknown device: require step-up verification. Known device: continue.

Identify in the browser

Serve the web agent from your own domain, then call identify when the form is submitted. The answer carries an eventId (agent 0.9.0 or later). Send only the eventId to your server. Do not send a visitor ID from the browser and trust it.

const { intentToken } = await (await fetch('/api/fpchecker/intent', { method: 'POST' })).json();
const { eventId } = await window.FpGuardAgent.identifyV2({
  baseUrl: 'https://api.fpchecker.com',
  publishableKey: 'pk_...',
  intentToken,
});
await fetch('/api/login', {
  method: 'POST',
  headers: { 'content-type': 'application/json' },
  body: JSON.stringify({ email, password, eventId }),
});

Backend integration reference →

Read the event on your server

Use the secret key (sk_), which stays on your server. The call is free and rate limited. If it fails, treat the login as unverified and ask for a second step: an error must never be read as a known device. Check the id format, that the event matches the id and is recent, and use each eventId once.

async function visitorFor(eventId) {
  if (!/^ev_[0-9A-HJKMNP-TV-Z]{20}$/.test(eventId)) return null; // fail closed
  const res = await fetch(`https://api.fpchecker.com/v1/events/${encodeURIComponent(eventId)}`, {
    headers: { 'X-Secret-Key': process.env.FPCHECKER_SECRET_KEY },
  });
  if (!res.ok) return null; // fail closed
  const event = await res.json();
  if (event.eventId !== eventId || !(Date.now() - Date.parse(event.createdAt) <= 5 * 60_000)) return null; // wrong or old event
  if (!(await markEventUsed(eventId))) return null; // your store: true only the first time
  return typeof event.visitorId === 'string' ? event : null;
}

Compare with the account’s devices

Keep a table of visitor IDs per account, filled after each successful login. Alternatively, ask for the visitor’s history with GET /v1/visitors/:visitorId, which returns its recent linkedIds; set linkedId to your user ID at identify and you can see which accounts this device has touched.

Never grant access on this check alone. It chooses between a normal login and a login that needs more proof.

app.post('/api/login', async (req, res) => {
  const user = await verifyPassword(req.body.email, req.body.password); // your code
  if (!user) return res.status(401).json({ error: 'invalid_credentials' });

  const event = await visitorFor(req.body.eventId);
  const known = event && (await db.knownVisitorIds(user.id)).includes(event.visitorId); // your code

  if (!known) {
    await sendStepUpCode(user); // your code: email or SMS code
    return res.status(202).json({ next: 'step_up' });
  }
  return res.json(await createSession(user)); // your code
});

Add risk evidence

The same event has signals. A new device that also shows a VPN, Tor or a bad bot result is a stronger case, and velocity counts show how many countries the device used lately. Use them to decide how strict the second step should be.

Signals on every event →

After the second step

When the user passes the step-up check, store the visitor ID for the account so the next login from this device goes straight through. Offer the user a way to review and remove devices.

Frequently asked questions

Why not send the visitorId from the browser?

A value that travels through the browser can be edited. The eventId is only a pointer; your server reads the real event with its secret key.

Does this stop account takeover?

It gives you evidence to require another proof on a new device. It does not replace passwords, multi-factor authentication or your other controls.

What if the same person changes browser?

A second browser first seen on the same computer and network usually gets the same visitor ID, so nothing changes. A browser first seen elsewhere is a new device to this check, so they get the second step once. That is the cost of the safer default.

Is the event lookup billed?

The events API is free and rate limited.

Try FingerPrint Checker free

Every new account gets 2,000 free checks. No card required to start.

Create a free account