Skip to main content

Overview

This tutorial walks through implementing Fingerprint to prevent account takeover. Account takeover (ATO) occurs when an attacker gains access to a real user’s account, often through stolen credentials, automated login abuse at scale, phishing, or other attacks aimed at your sign-in flow. You’ll begin with a starter app that includes a mock login page and a basic login flow. From there, you’ll add the JavaScript agent to identify each visitor and use server-side logic with Fingerprint data to detect and block automated login attempts. By the end, you’ll have a sample app that rejects high-risk traffic and can be customized to fit your use case and business rules. This tutorial uses just plain JavaScript and a Node server with SQLite on the backend. For language- or framework-specific setups, see the quickstarts.
Estimated time: < 15 minutes
This tutorial requires the Bot Detection Smart Signal, which is only available on paid plans.

Prerequisites

Before you begin, make sure you have the following:
  • A copy of the starter repository (clone with Git or download as a ZIP)
  • Node.js (v20 or later) and npm installed
  • Your favorite code editor
  • Basic knowledge of JavaScript

1. Create a Fingerprint account and get your API keys

  1. Sign up for a free Fingerprint trial, or log in if you already have an account.
  2. After signing in, go to the API keys page in the dashboard.
  3. Save your public API key, which you’ll use to initialize the JavaScript agent.
  4. Create and securely store a secret API key for your server. Never expose it on the client side. You’ll use this key on the backend to retrieve full visitor information through the Fingerprint Server API.

2. Set up your project

  1. Clone or download the starter repository and open it in your editor.
Terminal
git clone https://github.com/fingerprintjs/use-case-tutorials.git
cd use-case-tutorials/account-takeover
  1. This tutorial uses the account-takeover folder. The project is organized as follows:
public
index.html - Login page
index.js - Front-end logic to handle login
server
server.js - Serves static files and login endpoint
db.js - SQLite database connection
accounts.js - Login validation and account takeover checks
.env.example - Example environment variables
  1. From the account-takeover directory, install dependencies:
Terminal
npm install
  1. Copy or rename .env.example to .env, then add your Fingerprint API keys:
Terminal
FP_PUBLIC_API_KEY=your-public-key
FP_SECRET_API_KEY=your-secret-key
  1. Start the server:
Terminal
npm run dev
  1. Visit http://localhost:3000 to view the mock login page from the starter app. You can test the basic login form using the included test account (demo@example.com / password123) and clicking Log in.
  2. Then try to log in using the included headless bot test script test-bot.js. While the app is running, execute node test-bot.js and observe that the automated script logs in successfully. By default, the server does not distinguish between bots and real users.
Terminal
node test-bot.js

3. Add Fingerprint to the frontend

In this step, you’ll load the JavaScript agent when the page loads and trigger identification when the user clicks Log in. The JavaScript agent returns both a visitorId and a requestId. Instead of relying on the visitorId from the browser, you’ll send the requestId to your server along with the login payload. The server will then call the Fingerprint Events API to securely retrieve the full identification details, including bot detection and other signals.
  1. At the top of public/index.js, load the JavaScript agent:
public/index.js
const fpPromise = import(`https://fpjscdn.net/v3/${window.FP_PUBLIC_API_KEY}`).then(
  (FingerprintJS) => FingerprintJS.load({ region: 'us' }),
);
  1. Make sure to change region to match your workspace region (e.g., eu for Europe, ap for Asia, us for Global (default)).
  2. Near the bottom of public/index.js, the Log in button already has an event handler for submitting the credentials. Inside this handler, request visitor identification from Fingerprint using the get() method and include the returned requestId when sending the login to the server:
public/index.js
loginBtn.addEventListener("click", async () => {
  // ...

  const fp = await fpPromise;
  const { requestId } = await fp.get();

  try {
    const res = await fetch("/api/login", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ email, password, requestId }),
    });
    const data = await res.json();

    // ...
  }
});
The get() method sends signals collected from the browser to Fingerprint servers, where they are analyzed to identify the visitor. The returned requestId acts as a reference to this specific identification event, which your server can later use to fetch the full visitor details. For lower latency in production, use Sealed Client Results to return full identification details as an encrypted payload from the get() method.

4. Receive and use the request ID to get visitor insights

Next, pass the requestId through to your login logic, initialize the Fingerprint Node Server SDK, and fetch the full visitor identification event so you can access the trusted visitor ID, Bot Detection, and other Smart Signals.
  1. In the backend, the server/server.js file defines the API routes for the app. Update the /api/login route there to also extract requestId from the request body and pass it into the attemptLogin function:
server/server.js
app.post('/api/login', async (req, reply) => {
  const { email, password, requestId } = req.body || {};
  const result = await attemptLogin({ email, password, requestId });
  return reply.send(result);
});
  1. The server/accounts.js file contains the logic for handling logins. Start by importing and initializing the Fingerprint Node Server SDK there, and load your environment variables with dotenv.
server/accounts.js
import { db } from './db.js';
import { config } from 'dotenv';
import { FingerprintJsServerApiClient, Region } from '@fingerprintjs/fingerprintjs-pro-server-api';

config();

const fpServerApiClient = new FingerprintJsServerApiClient({
  apiKey: process.env.FP_SECRET_API_KEY,
  region: Region.Global,
});
  1. Make sure to change region to match your workspace region (e.g., EU for Europe, AP for Asia, Global for Global (default)).
  2. Update the attemptLogin function to accept requestId and use it to fetch the full identification event details from Fingerprint:
server/accounts.js
export async function attemptLogin({ email, password, requestId }) {
  if (!email || !password) {
    console.error('Missing credentials.');
    return { success: false, error: 'Login failed.' };
  }

  if (!requestId) {
    console.error('Missing requestId.');
    return { success: false, error: 'Login failed.' };
  }

  const event = await fpServerApiClient.getEvent(requestId);

  const user = findAccountByEmail(email);
  if (!user || user.password !== password) {
    console.error('Invalid credentials');
    return { success: false, error: 'Login failed.' };
  }

  return { success: true };
}
Using the requestId, the getEvent method will retrieve the full data for the visitor identification event. The returned object will contain the visitor ID, IP address, device, and browser details, and Smart Signals like bot detection, browser tampering detection, VPN detection, and more. You’ll use those fields in the next steps to decide whether a sign-in attempt looks like account takeover or other abuse. You can see a full example of the event structure and test it with your own device in the demo playground. For additional checks to ensure the validity of the data coming from your frontend, view how to protect from client-side tampering and replay attacks.

5. Block logins after too many recent failed attempts

Takeover attempts and password guessing often produce many failures from the same device, even when the attacker rotates usernames. Once you can reliably associate attempts with a Fingerprint visitor ID, you can persist outcomes and rate-limit or block visitors that exceed a threshold of recent failures—before or alongside other checks. The starter app includes a SQLite login_attempts table for this demo. Each row records the visitor ID, account email, whether the attempt succeeded, and a timestamp:
SQLite database tables
accounts - Stores login credentials for test accounts
  email TEXT PRIMARY KEY
  password TEXT NOT NULL

login_attempts - Records each login attempt for rate limits and device history
  id INTEGER PRIMARY KEY AUTOINCREMENT
  visitorId TEXT NOT NULL
  email TEXT NOT NULL
  success INTEGER NOT NULL (0 = failed, 1 = succeeded)
  createdAt INTEGER NOT NULL
  1. Add helper functions at the bottom of server/accounts.js to record failures and count how many failed attempts a visitor had in the last 24 hours:
server/accounts.js
function logFailedAttempt(visitorId, email) {
  db.prepare(
    `INSERT INTO login_attempts (visitorId, email, success, createdAt) VALUES (?, ?, 0, ?)`,
  ).run(visitorId, email, Date.now());
}

function getRecentFailedAttempts(visitorId) {
  const since = Date.now() - 24 * 60 * 60 * 1000;
  const row = db
    .prepare(
      `SELECT COUNT(*) as count
       FROM login_attempts
       WHERE visitorId = ? AND success = 0 AND createdAt >= ?`,
    )
    .get(visitorId, since);
  return row.count;
}
  1. In attemptLogin, after you call getEvent, read visitorId from event.products.identification.data. Then before validating the password, block visitors with too many recent failures. On any failed outcome in this flow, record a row with logFailedAttempt so later attempts stay counted.
At this stage, attemptLogin should look like this:
server/accounts.js
export async function attemptLogin({ email, password, requestId }) {
  if (!email || !password) {
    console.error('Missing email or password.');
    return { success: false, error: 'Login failed.' };
  }

  if (!requestId) {
    console.error('Missing requestId.');
    return { success: false, error: 'Login failed.' };
  }

  const event = await fpServerApiClient.getEvent(requestId);
  const visitorId = event.products.identification.data.visitorId;

  if (getRecentFailedAttempts(visitorId) >= 3) {
    logFailedAttempt(visitorId, email);
    console.error('Too many failed login attempts.');
    return { success: false, error: 'Login failed.' };
  }

  const user = findAccountByEmail(email);
  if (!user || user.password !== password) {
    logFailedAttempt(visitorId, email);
    console.error('Invalid credentials');
    return { success: false, error: 'Login failed.' };
  }

  return { success: true };
}

6. Block unrecognized devices from logging in

Valid passwords are often used from new devices during account takeover. A practical pattern is to require a prior successful login from the same visitor ID for that user before treating the session as routine, or to step up authentication (MFA, OTP, etc.) when that history is missing. This tutorial is simplified and will block unrecognized devices from logging in for demonstration purposes. For the first successful login for an account there is no history yet, so allow it and establish a baseline. For later logins, if the account already has at least one successful login stored, but this visitor ID has never succeeded for that email, block as an unrecognized device.
  1. Add helpers to query and record successful attempts by email and visitor ID:
server/accounts.js
function hasAnySuccessfulLogin(email) {
  return !!db
    .prepare(`SELECT 1 FROM login_attempts WHERE email = ? AND success = 1 LIMIT 1`)
    .get(email);
}

function hasSuccessfulLoginForVisitor(email, visitorId) {
  return !!db
    .prepare(
      `SELECT 1 FROM login_attempts WHERE email = ? AND visitorId = ? AND success = 1 LIMIT 1`,
    )
    .get(email, visitorId);
}

function logSuccessfulAttempt(visitorId, email) {
  db.prepare(
    `INSERT INTO login_attempts (visitorId, email, success, createdAt) VALUES (?, ?, 1, ?)`,
  ).run(visitorId, email, Date.now());
}
  1. Within attemptLogin, after the password matches, add the check before returning and logging a successful attempt. If the account already has successful logins but this visitor ID has not, log a failed attempt and return an error:
server/accounts.js
const accountHasSuccessfulLogins = hasAnySuccessfulLogin(email);
const visitorHasLoggedInBefore = hasSuccessfulLoginForVisitor(email, visitorId);

if (accountHasSuccessfulLogins && !visitorHasLoggedInBefore) {
  logFailedAttempt(visitorId, email);
  console.error('Unrecognized device.');
  return {
    success: false,
    error: 'Login failed.',
  };
}

logSuccessfulAttempt(visitorId, email);
return { success: true };

7. Block bots

Automated scripts and headless browsers often show up in account takeover workflows. Fingerprint Bot Detection helps differentiate real users from abusive automation. Fingerprint returns notDetected if no bot activity is found, good for known bots (for example known search engines, verified AI agents, etc.), and bad for other automation.
  1. Continuing in attemptLogin, add a bot check on the same event you already fetched, then a Suspect Score check (optional but useful as a secondary layer for suspicious traffic):
server/accounts.js
const botDetected = event.products?.botd?.data?.bot?.result !== 'notDetected';
if (botDetected) {
  logFailedAttempt(visitorId, email);
  console.error('Bot detected.');
  return { success: false, error: 'Login failed.' };
}

const suspectScore = event.products?.suspectScore?.data?.result || 0;
if (suspectScore > 20) {
  logFailedAttempt(visitorId, email);
  console.error(`High Suspect Score detected: ${suspectScore}`);
  return { success: false, error: 'Login failed.' };
}

logSuccessfulAttempt(visitorId, email);
return { success: true };
Suspect Score is a weighted rollup of Smart Signals. In production you might use it to step up authentication rather than hard-block, depending on your business rules. Together, rate limits by visitor ID, device history, and bot detection give you a layered defense against account takeover. You can extend this with more Smart Signals, different thresholds, or alternate responses.
This is a minimal example to show how to implement Fingerprint. In a real application, make sure to implement proper security practices, error checking, and password handling that align with your production standards.

8. Test your implementation

Now that everything is wired up, you can test the full protected login flow.
  1. Start your server if it isn’t already running and open http://localhost:3000:
Terminal
npm run dev
  1. Log in once with a valid test account (demo@example.com / password123). You will see a success response, and that browser’s visitor ID will be recorded as a known trusted device for that account.
  2. Try several failed login attempts using the wrong password from the same browser. After three failures within the rolling time window, the recent failed attempts check blocks that visitor ID, including with the correct password, until the window rolls forward or you reset the demo database.
  3. Reset the demo database, then log in successfully again from your browser so there is a known trusted device on file for demo@example.com.
  4. With that successful browser session in place, open the demo in a completely different browser. Attempt to log in with the correct credentials. Since the new browser has no prior successful login, the login will be rejected as an unrecognized device
  5. Next, run the headless test script from the account-takeover directory. It uses the correct credentials but because it is a headless browser, the login will be rejected as a bot:
Terminal
node test-bot.js
Note: If you encounter errors launching the automated browser, make sure you have the testing browser installed:
Terminal
npx puppeteer browsers install chrome

Next steps

You now have a working login flow that layers visitor-based rate limiting, device history, and bot detection with Fingerprint for account takeover protection. From here, you can expand the logic with more Smart Signals, fine-tune rules based on your business policies, or layer in additional defenses such as multi-factor authentication. To dive deeper, explore the other use case tutorials for more step-by-step examples. Check out these related resources: