Protecting from client-side tampering and replay attacks

Prevent malicious users from bypassing Fingerprint by integrating it with your server and checking the identification results for freshness and consistency with the associated HTTP requests.

Fingerprint provides you with device intelligence about the client environment of your users — browsers visiting your website or mobile devices using your mobile application.

Fingerprint needs to run on the client to analyze it. But in most cases, you shouldn't use the provided visitor ID directly in the client nor send it in plain text from the client to the server. Client environments (browsers and mobile devices) can be tampered with and any functionality or security measures relying on client-side logic or data can be bypassed.

In this guide, we will start with a bad example of a Fingerprint implementation vulnerable to client-side tampering. Then you will learn how to receive, validate, and use Fingerprint results in the safety of your server environment. We will use a website as an example, but the same principles also apply to mobile applications.

How NOT to integrate Fingerprint

Imagine you are running a survey on your website and you don't want to accept more than one submission from a single browser. A simple implementation vulnerable to client-side tampering might look like this:

<script>
  // Load the Fingerprint JavaScript agent
  const fpPromise = import('https://fpjscdn.net/v3/YOUR_PUBLIC_API_KEY').then((FingerprintJS) =>
    FingerprintJS.load(),
  );

  document
    .getElementById('surveyForm')
    .addEventListener('submit', function (e) {
    	e.preventDefault();
    	const data = Object.fromEntries(new FormData(e.target));
    
      // ❌ EXAMPLE OF UNSECURE PRACTICE
      // When the form is submitted, get the visitor ID and 
      // send it to the server for processing alongside the form 
      fpPromise
        .then((fp) => fp.get())
        .then((result) => {
          console.log(result.visitorId);

          fetch(`/api/survey`, {
            method: 'POST',
            headers: {
              'Content-Type': 'application/json',
            },
            body: JSON.stringify({
              data,
              visitorId: result.visitorId,
            }),
          });
        });
  });
</script>
export async function POST(request) {
  /**
   * ❌ EXAMPLE OF UNSECURE PRACTICE
   * The visitorId provided from the client could have been spoofed
   */
  const { visitorId, data } = (await request.json());
  if (await submissionsDatabase.has(visitorId)) {
    return Response.json(
      {
        message: 'This browser already participated in the survey',
      },
      { status: 403 },
    );
  }

  submissionsDatabase.set(visitorId, data);
  return Response.json({ status: 'OK' });
}
  • A person trying to submit hundreds of votes can bypass your website completely and simply spam requests to yourwebsite.com/api/survey using an HTTP library like cURL and supplying a random visitorId with each request.
  • Even if you limit your endpoint to only accept requests from your website's origin, an attacker can use a browser extension to intercept and modify the in-browser request.

To ensure your server is dealing with a Visitor ID generated by Fingerprint, you need to get the identification result to your server securely. Besides security concerns, the JavaScript agent (or a mobile SDK) only provides you with a visitor ID, and the full set of Smart signals is only available through one of the delivery mechanisms below.

Getting the Fingerprint results to your server securely

You have three options to get the Fingerprint results to your server safely:

  • Server API
    • Pass only the requestId from the JavaScript agent to your server, use it to retrieve the full identification event from the Server API.
    • The result authenticity is ensured by direct server-to-server communication.
  • Webhooks
    • Pass only the requestId from the JavaScript agent to your server, use it to wait for the corresponding identification event Webhook from Fingerprint.
    • The result authenticity is ensured by direct server-to-server communication.
    • This implementation is the least common. It is usually more complex and has higher latency than Server API or Sealed results. Webhooks are typically used for event storage.
  • Sealed results
    • Receive the full identification event in the JavaScript agent as an encrypted Sealed result. Send it encrypted to your server and decrypt it there using a secret key generated using the Fingerprint Dashboard.
    • The result authenticity is ensured by symmetric encryption.
    • This implementation is faster than Webhooks and Server API (it requires one less HTTP request). It is currently available for the JavaScript agent (no mobile support yet) and only for customers on the Enterprise plan.

Here is the example from before, now using the Server API /events endpoint to safely retrieve the full identification result. For the other alternatives, see Webhooks or Sealed results.

<script>
  const fpPromise = import('https://fpjscdn.net/v3/YOUR_PUBLIC_API_KEY').then(
    (FingerprintJS) => FingerprintJS.load(),
  );

  document
    .getElementById('surveyForm')
    .addEventListener('submit', function (e) {
      e.preventDefault();
      const data = Object.fromEntries(new FormData(e.target));

      /**
       * ✅ EXAMPLE OF GOOD PRACTICE
       * Send only the `requestId`  to the server
       */
      fpPromise
        .then((fp) => fp.get())
        .then((result) => {
          fetch(`/api/survey`, {
            method: 'POST',
            headers: {
              'Content-Type': 'application/json',
            },
            body: JSON.stringify({
              data,
              requestId: result.requestId,
            }),
          });
        });
    });
</script>
export async function POST(request) {
  /**
   * ✅ EXAMPLE OF GOOD PRACTICE
   * Get only the `requestId` from the client and use it
   * to retrieve the full identification event from Server API
   */
  const { requestId, data } = (await request.json());
  const identificationEvent = await (
    await fetch(`https://eu.api.fpjs.io/events/${requestId}`, {
      headers: { 'Content-Type': 'application/json', 'Auth-API-Key': SUBS.main.serverApiKey },
    })
  ).json();

  const visitorId = identificationEvent?.products?.identification?.data?.visitorId;
  if (!visitorId) {
    throw new Error('Could not retrieve visitor ID.');
  }

  if (await submissionsDatabase.has(visitorId)) {
    return Response.json(
      {
        message: 'You already submitted this survey',
      },
      { status: 403 },
    );
  }

  submissionsDatabase.set(visitorId, data);
  return Response.json({ status: 'OK' });
}

At this point, you know you are dealing with a real visitor ID generated by Fingerprint for your application. However, you still need to verify that the request ID belongs to this specific survey submission request. An attacker might have intercepted previously issued request IDs or generated their own using your Public API key. This is known as a "replay attack". They can be prevented by the freshness and consistency checks described below.

📘

Related: Hiding the Visitor ID from the client completely

Hiding the Visitor ID from the client payload makes it harder for malicious actors to reverse-engineer your implementation. You have two options available:

  • Using Sealed results to encrypt the payload returned to the JavaScript agent completely.
  • Using Zero Trust Mode to omit the visitor ID and other metadata from the JavaScript agent payload, and only send the requestIdto the client.

Both options require the Enterprise plan.

Preventing replay attacks

During a replay attack, an attacker intercepts and retransmits data previously exchanged between Fingerprint and your server.

  • When using Webhooks, you can verify that a Webhook request came from Fingerprint using Basic authentication or Webhook signatures. This prevents replay attacks.
  • When using Sealed results, an attacker can intercept the sealed result your client sends to your server.
  • When using the Server API, an attacker can intercept previously issued request IDs or generate their own using your Public API key.

An attacker can include the intercepted request ID or sealed result with their HTTP request to your server. This would allow them to spoof the visitor ID and other Smart signals associated with their malicious action. To prevent replay attacks, you have two approaches to choose from (or even combine):

  • a) Save all request IDs received from the client (sealed or not) or webhooks into a database. When a new request ID comes in that is already present in the database, reject the request.
  • b) Check the freshness of the retrieved identification event and its consistency with the HTTP request itself.

A) Saving all request IDs to a database

Here is a simplified example of saving the received request ID into a database and processing only previously unseen request IDs.

export async function POST(request: Request) {
  /** Get the `requestId` from the request payload (sealed or not) */
  const { requestId } = parseRequestId(await request.json());

  // Only process the request if it hasn't been processed before
  if (await requestIdDatabase.has(requestId)) {
    return Response.json(
      { message: 'Already processed this request ID, potential replay attack' },
      { status: 403 },
    );
  }

  await requestIdDatabase.set(requestId, true);
  // Continue processing the request
}

Note: If you are using Sealed results, it's a good idea to pass the unencrypted request ID alongside the sealed result to your server as a fallback. The request ID decrypted from the sealed result must the match the one sent without encryption. See Sealed results for more details.

B) Checking the freshness and consistency of the identification result

The code snippets below are simplified for readability, but you can find the full example as used in our use case demos on GitHub. These are common good practices that you might need to adjust according to your specific use case.

/**
 * Validates the consistency of the Fingerprint identification result with the associated HTTP request
 *
 * @param {EventResponse} identificationEvent - The event retrieved from Server API or Sealed result
 * @param {Request} request - The HTTP request (for example, the survey submission request)
 * @returns {{ okay: boolean, error?: string} } - An object indicating the validation result.
 */
function validateFingerprintResult(identificationEvent, request) {
  const identification = identificationEvent.products?.identification?.data;

  // The expected data might be missing completely.
  if (!identification) {
    return { okay: false, error: 'Identification event not found, potential spoofing attack.' };
  }
  
  // More checks to follow...
  
  return { okay: true };
}

export async function POST(request: Request) {
  const { requestId, data } = (await request.json()) as Payload;
  const identificationEvent = await (
    await fetch(`https://eu.api.fpjs.io/events/${requestId}`, {
      headers: {
        'Content-Type': 'application/json',
        'Auth-API-Key': SUBS.main.serverApiKey,
      },
    })
  ).json();

  /**
   * ✅ EXAMPLE OF GOOD PRACTICE
   * Validate the consistency of the Fingerprint identification result with the associated HTTP request
   * Check timestamps, IP, origin and other metadata to prevent replay attacks
   */
  const { okay, error } = validateFingerprintResult(
    identificationEvent,
    request,
  );
  if (!okay) {
    return Response.json({ error }, { status: 403 });
  }

  const visitorId =
    identificationEvent?.products?.identification?.data?.visitorId;
  if (await submissionsDatabase.has(visitorId)) {
    return Response.json({ message: 'Already submitted' }, { status: 403 });
  }

  submissionsDatabase.set(visitorId, data);
  return Response.json({ status: 'OK' });
}

1. Check the timestamp

If the identification request associated with the survey submission is too old, reject the request. Pick a time limit suitable for your infrastructure setup and use case.

// Check freshness of the identification request to prevent replay attacks.
const ALLOWED_REQUEST_TIMESTAMP_DIFF_MS = 3000;
if (Date.now() - Number(new Date(identification.time)) > ALLOWED_REQUEST_TIMESTAMP_DIFF_MS) {
  return { okay: false, error: 'Old identification request, potential replay attack.' };
}

2. Check the origin

Both the Fingerprint identification request and the survey submission request should be coming from your website.

const identificationOrigin = new URL(identification.url).origin;
const requestOrigin = request.headers.get('origin');
if (
  identificationOrigin !== requestOrigin ||
  identificationOrigin !== 'https://yourwebsite.com' ||
  requestOrigin !== 'https://yourwebsite.com'
) {
  return { okay: false, error: 'Unexpected origin, potential replay attack.' };
}

3. Check the IP address

The Fingerprint identification request and the survey submission request should be coming from the same IP address.

/**
 * Note: Parsing the user IP from `x-forwarded-for` can be unreliable as
 * any proxy between your server and the visitor can overwrite or spoof the header.
 * In most cases, using the right-most external IP is more appropriate
 * than the left-most one as is demonstrated here. You might need to adjust or skip
 * this check depending on your use case and server configuration.
 * You can learn more at:
 * https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Forwarded-For
 * https://adam-p.ca/blog/2022/03/x-forwarded-for/.
 */
const parseIp = (request) => {
  const xForwardedFor = request.headers.get('x-forwarded-for');
  const requestIp = Array.isArray(xForwardedFor)
    ? xForwardedFor[0]
    : xForwardedFor?.split(',')[0] ?? '';
  return requestIp;
};

const identificationIp = identification.ip;
const requestIp = parseIp(request);

// This check currently works only for IPv4 addresses.
if (IPv4_REGEX.test(requestIp) && identificationIp !== requestIp) {
  return {
    okay: false,
    error: 'Unexpected IP address, potential replay attack.',
  };
}

4. Optional: Check the confidence score

A confidence score indicates how confident Fingerprint is that the browser (or device) in the request has not been misidentified for another existing browser (or device).

You can use it in your validation logic to only allow users identified above a certain threshold of confidence to perform a sensitive action. See Understanding your confidence score for more details.

if (identification.confidence.score < env.MIN_CONFIDENCE_SCORE) {
  return { okay: false, error: 'Low confidence score, actions requires 2FA.' };
}

Using Smart signals

The validateFingerprintResult method is a good place to use all the Smart Signals Fingerprint offers for detecting suspicious browser and device configurations.

For example, you can prevent bots, VPN users, Tor browser users, and users tampering with their browser configurations from submitting survey responses:

if (identificationEvent.products?.botd?.data?.bot?.result === 'bad') {
  return { okay: false, error: 'Malicious bot detected.' };
}

if (identificationEvent.products?.vpn?.data?.result === true) {
  return { okay: false, error: 'VPN network detected.' };
}

if (identificationEvent.products?.tor?.data?.result === true) {
  return { okay: false, error: 'Tor network detected.' };
}

if (identificationEvent.products?.tampering?.data?.result === true) {
  return { okay: false, error: 'Browser tampering detected.' };
}

What's next

  • Have a look at our demo collection to see how the patterns in this guide are implemented for specific use cases. The source code is open-source and available on GitHub.
  • If you have more questions about protecting your implementation from tampering and replay attacks, please reach out to our support team.