DEV Community

Cover image for Detecting Account Takeover Attempts with Fingerprint
Shadai Scott
Shadai Scott

Posted on

Detecting Account Takeover Attempts with Fingerprint

When a hacker executes an account takeover (ATO), their main goal is to gain control of an account and exploit it for profit. For SaaS platforms this is dangerous for three reasons:

  • High-Value Targets and Rich Data: SaaS platforms act as central repositories for sensitive customer data, financial records, and intellectual property.

  • Difficulty in Detection: Once attackers are inside a SaaS platform, their actions often look like normal employee behaviour. They download files, share documents or even send messages.

  • Monetization Opportunities: Stolen SaaS accounts are valuable for stealing money, accessing financial apps, selling access on the black market, and launching further scams.

Traditional defenses like IP blocking fall short because attackers rotate IPs constantly. This guide shows you how to use Fingerprint's device intelligence to identify suspicious login attempts at the device level before access is granted.

Prerequisites

How Fingerprint Identifies Suspicious Logins

Fingerprint generates a stable visitor ID by creating a unique signature based on a device's inherent hardware and software configurations, rather than relying on stored data like cookies. This identifier is persistent because it relies on the machine's actual characteristics. This makes it difficult to change, allowing it to bypass incognito mode, cookie deletion, and VPN-based IP masking.

For every identification request, a confidence score and suspect score is computed internally. The confidence score indicates the probability of a false-positive identification. It is a floating-point number whose value ranges from 0 to 1. The higher the value, the more confident Fingerprint is about not misidentifying this browser as another.

You can use the confidence score to request additional verification. For example, request OTP for browsers whose confidence score is below 0.95.

Suspect Score is a single value representing how many Smart Signals indicative of suspicious or fraudulent activity were triggered for a particular Event (identified by an event_id). Each triggered Smart Signal is represented by a positive integer called a weight. Weights are additive, meaning that the more Smart Signals return a positive value, the higher the final score.

Suspect Score can be used to flag and review possible suspicious activity. It can be used to quickly integrate Smart Signals into your fraud protection workflow without the need to explore fraud correlations of individual Smart Signals.

Installing the Fingerprint Javascript Agent

The Fingerprint JavaScript agent is a high-performance JavaScript function that collects multiple device and browser signals and sends them to the Fingerprint API for visitor identification and device intelligence analysis.

There are several options on how the FingerPrint Javascript Agent can be installed. These are:

  • Import the agent directly from a CDN (fastest for a quick test).
  • Install the agent loader using NPM (includes TypeScript support).
  • Use a frontend SDK for your specific framework like React, Vue, Svelte, or Angular (recommended, includes built-in caching strategies and framework-specific APIs).

In this guide we will use the Vue SDK framework as an example.

Installation Guide

  • Get API Key from your dashboard and add to your .env file.
VITE_FINGERPRINT_API_KEY=your_public_api_key_here
Enter fullscreen mode Exit fullscreen mode
  • Install the fingerprint dependency into the root of your project. This can be installed via npm or yarn.
npm install @fingerprintjs/fingerprintjs-pro-vue-v3
yarn add @fingerprintjs/fingerprintjs-pro-vue-v3
Enter fullscreen mode Exit fullscreen mode
  • Register the plugin in your Vue.js application. This snippet can be found on Fingerprint under the Vue Integration.
// src/main.js
import { createApp } from 'vue';
import App from './App.vue';
import {
  fpjsPlugin
} from '@fingerprintjs/fingerprintjs-pro-vue-v3';

const app = createApp(App);

app
  .use(fpjsPlugin, {
    loadOptions: {
      apiKey: import.meta.env.VITE_FINGERPRINT_API_KEY
    },
  })
  .mount('#app');
Enter fullscreen mode Exit fullscreen mode
  • Use the useVisitorData hook in your components to identify visitors. This is a sample of how it can be implemented.
<script setup lang="ts">
import { ref } from 'vue';
import { useVisitorData } from '@fingerprintjs/fingerprintjs-pro-vue-v3';
import { useRouter } from 'vue-router';

const email = ref('');
const password = ref('');
const isLoading = ref(false);
const error = ref('');
const fpResult = ref<null | {
  visitorId: string;
  requestId: string;
  confidence: number;
  suspectScore: number;
}>(null);

const router = useRouter();
const { getData } = useVisitorData(
  { extendedResult: true },
  { immediate: false }
);

function getRiskLevel(confidence: number, suspectScore: number) {
  if (suspectScore >= 2 || confidence < 0.85) return 'high';
  if (suspectScore === 1 || confidence < 0.95) return 'medium';
  return 'low';
}

async function handleLogin() {
  if (!email.value || !password.value) return;

  isLoading.value = true;
  error.value = '';
  fpResult.value = null;

  try {
    // Always get a fresh result on login — never use cached data for security checks
    const fpData = await getData({ ignoreCache: true });

    if (!fpData) throw new Error('Fingerprint identification failed');

    const { visitorId, requestId, confidence, suspectScore } = fpData as any;

    fpResult.value = {
      visitorId,
      requestId,
      confidence: confidence?.score ?? 0,
      suspectScore: suspectScore ?? 0,
    };

    // Send credentials + Fingerprint data to your backend
    const response = await fetch('/api/login', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        email: email.value,
        password: password.value,
        visitorId,
        requestId,
      }),
    });

    const result = await response.json();

    if (!response.ok) {
      error.value = result.message || 'Login failed';
      return;
    }

    if (result.requiresOtp) {
      router.push('/verify-otp');
      return;
    }

    router.push('/dashboard');

  } catch (err) {
    error.value = 'Something went wrong. Please try again.';
  } finally {
    isLoading.value = false;
  }
}
</script>

<template>
  <div class="min-h-screen bg-gray-50 flex items-center justify-center px-4">
    <div class="w-full max-w-md">

      <!-- Card -->
      <div class="bg-white border border-gray-200 rounded-2xl p-8 shadow-sm">

        <!-- Logo / Header -->
        <div class="flex items-center gap-3 mb-8">
          <div class="w-9 h-9 rounded-lg bg-indigo-600 flex items-center justify-center shrink-0">
            <svg class="w-5 h-5 text-white" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
              <path d="M12 2L4 6v6c0 5.25 3.5 10.15 8 11.35C16.5 22.15 20 17.25 20 12V6L12 2z" fill="currentColor" opacity="0.9"/>
              <path d="M9 12l2 2 4-4" stroke="white" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
            </svg>
          </div>
          <div>
            <p class="text-sm font-medium text-gray-900 leading-tight">Secure sign in</p>
            <p class="text-xs text-gray-400 leading-tight mt-0.5">Protected by Fingerprint</p>
          </div>
        </div>

        <!-- Form -->
        <form @submit.prevent="handleLogin" class="space-y-5">

          <!-- Email -->
          <div class="space-y-1.5">
            <label for="email" class="block text-sm text-gray-500">
              Email address
            </label>
            <input
              id="email"
              v-model="email"
              type="email"
              autocomplete="email"
              placeholder="you@example.com"
              required
              class="w-full h-10 px-3 text-sm bg-gray-50 border border-gray-200 rounded-lg text-gray-900 placeholder-gray-300 outline-none transition focus:border-indigo-400 focus:ring-2 focus:ring-indigo-100"
            />
          </div>

          <!-- Password -->
          <div class="space-y-1.5">
            <label for="password" class="block text-sm text-gray-500">
              Password
            </label>
            <input
              id="password"
              v-model="password"
              type="password"
              autocomplete="current-password"
              placeholder="••••••••"
              required
              class="w-full h-10 px-3 text-sm bg-gray-50 border border-gray-200 rounded-lg text-gray-900 placeholder-gray-300 outline-none transition focus:border-indigo-400 focus:ring-2 focus:ring-indigo-100"
            />
          </div>

          <!-- Error message -->
          <p v-if="error" class="text-xs text-red-500 bg-red-50 border border-red-100 rounded-lg px-3 py-2">
            {{ error }}
          </p>

          <!-- Submit -->
          <button
            type="submit"
            :disabled="isLoading"
            class="w-full h-10 text-sm font-medium rounded-lg transition active:scale-[0.98] disabled:opacity-50 disabled:cursor-not-allowed"
            :class="isLoading
              ? 'bg-indigo-400 text-white cursor-not-allowed'
              : 'bg-indigo-600 hover:bg-indigo-700 text-white'"
          >
            <span v-if="isLoading" class="flex items-center justify-center gap-2">
              <svg class="w-4 h-4 animate-spin" viewBox="0 0 24 24" fill="none">
                <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/>
                <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z"/>
              </svg>
              Verifying device...
            </span>
            <span v-else>Sign in</span>
          </button>

        </form>

        <!-- Fingerprint result panel -->
        <div
          v-if="fpResult"
          class="mt-5 rounded-lg border px-4 py-3 text-xs space-y-2"
          :class="{
            'bg-green-50 border-green-100': getRiskLevel(fpResult.confidence, fpResult.suspectScore) === 'low',
            'bg-yellow-50 border-yellow-100': getRiskLevel(fpResult.confidence, fpResult.suspectScore) === 'medium',
            'bg-red-50 border-red-100': getRiskLevel(fpResult.confidence, fpResult.suspectScore) === 'high',
          }"
        >
          <!-- Risk badge -->
          <div class="flex items-center justify-between mb-1">
            <span class="font-medium text-gray-700">Fingerprint result</span>
            <span
              class="text-xs font-medium px-2 py-0.5 rounded-full"
              :class="{
                'bg-green-100 text-green-700': getRiskLevel(fpResult.confidence, fpResult.suspectScore) === 'low',
                'bg-yellow-100 text-yellow-700': getRiskLevel(fpResult.confidence, fpResult.suspectScore) === 'medium',
                'bg-red-100 text-red-700': getRiskLevel(fpResult.confidence, fpResult.suspectScore) === 'high',
              }"
            >
              {{
                getRiskLevel(fpResult.confidence, fpResult.suspectScore) === 'low' ? 'clear' :
                getRiskLevel(fpResult.confidence, fpResult.suspectScore) === 'medium' ? 'review' :
                'high risk'
              }}
            </span>
          </div>

          <!-- Signal rows -->
          <div class="divide-y divide-gray-100 border-t border-gray-100 pt-2 space-y-1.5">
            <div class="flex justify-between pt-1">
              <span class="text-gray-400">visitorId</span>
              <span class="font-mono text-gray-700 truncate max-w-[55%]">{{ fpResult.visitorId }}</span>
            </div>
            <div class="flex justify-between pt-1">
              <span class="text-gray-400">requestId</span>
              <span class="font-mono text-gray-700 truncate max-w-[55%]">{{ fpResult.requestId }}</span>
            </div>
            <div class="flex justify-between pt-1">
              <span class="text-gray-400">confidence score</span>
              <span class="font-mono text-gray-700">{{ fpResult.confidence.toFixed(2) }}</span>
            </div>
            <div class="flex justify-between pt-1">
              <span class="text-gray-400">suspect score</span>
              <span class="font-mono text-gray-700">{{ fpResult.suspectScore }}</span>
            </div>
          </div>

          <p class="text-gray-400 pt-1">
            In production, <code class="text-gray-500">visitorId</code> and <code class="text-gray-500">requestId</code>
            are sent to your Firebase Cloud Function for server-side Smart Signal verification.
          </p>
        </div>

        <!-- Footer note -->
        <p class="text-xs text-gray-300 text-center mt-6">
          Fingerprint collects device signals on submission
        </p>

      </div>
    </div>
  </div>
</template>
Enter fullscreen mode Exit fullscreen mode

Server-Side Verification with Smart Signals

Verification should be done server-side with Smart Signals fingerprinting primarily to ensure the security, integrity, and accuracy of fraud detection data, as client-side environments (browsers/mobile apps) are easily tampered with by malicious actors.

After your Vue component collects the visitorId and requestId from Fingerprint, those two values need to be sent to a backend for verification. This step is not optional as Smart Signals are deliberately unavailable in the client-side response to prevent tampering.

The flow looks like this:

  • Vue form collects vistorId and requestId on submission.

  • Both values are sent to your backend along with the user's credentials.

  • Your backend calls the Fingerprint Server API using requestId to retrieve the full Smart Signals for that login events.

  • Your backend evaluates the signals and decides whether to allow, challenge with OTP, or block.

  • Your backend returns the decision to the Vue application.

What each backend response means for your UI

  • 200 OK with no requiresOtp flag: Login is clean, redirect to dashboard.

  • 200 OK with requiresOtp: true: Fingerprint flagged the device as medium risk (VPN detected, low confidence score, or a single suspect signal). Route the user to an OTP screen before granting access. Pass visitorId along as a query param so your OTP handler can reference the same device context.

  • 401 Unauthorized : The backend detected high-risk signals (bot activity, browser tampering, blocklisted IP). Show an error and do not proceed. Do not tell the user exactly why they were blocked. A vague message is intentional.

Device fingerprinting is one layer in a broader account security strategy not a silver bullet. Pair it with rate limiting, multi-factor authentication, and anomaly detection to build a login flow that's genuinely difficult to compromise.

Top comments (0)