Sealed Client Results

Receive an encrypted payload in the JS agent with all information available in the /events endpoint, meant to be decrypted and used on your backend.

🚧

Limitations

  • Minimum JavaScript agent version is 3.9.0
  • Currently, Sealed Client Results are only available to customers on the Enterprise plan.
  • Sealed Client Results are supported in the JavaScript agent only for now. If you'd like to use them in our mobile SDKs as well, let us know by leaving feedback on our public roadmap.

How Sealed Client Results work

Sealed Client Results is an alternative delivery mechanism for information normally available only through our Server API. Usually, you would need to call the Server API on your backend to:

  • Validate the authenticity of the visitor ID and other information sent from the client.
  • Get the full identification event, including Smart Signals.

With Sealed Results, the client agent response payload contains the same JSON structure that is available through the /events Server API endpoint. This has two main advantages:

  • Reduced end-to-end latency — unsealing the result happens on your server, no need to call the Server API.
  • Increased secrecy — the payload is fully encrypted with a symmetric key, making it impossible for a malicious actor to read or modify the results on the client, similar to Zero Trust Mode.

Sealed results make it faster, safer, and more straightforward to integrate Fingerprint Identification results into your application.

Data flow

Sealed Client Results Data Flow

Sealed Client Results Data Flow

Configuring Sealed Client Results

If you want to use Sealed Client Results, complete the following steps (the order is important):

  1. Generate an Encryption key in the Dashboard.
  2. Set up your backend to accept Sealed Results and decrypt them.
  3. Prepare your frontend to start sending the encrypted payload to the backend.
  4. Activate the Encryption key in the Dashboard to start receiving Sealed Client Results in the JS agent response.

Step 1: Create an encryption key in the Dashboard

  1. Navigate to App Settings > API Keys > Encryption Keys.
  1. Click Create Encryption Key.
  2. Fill in a recognizable name into the form that pops up.
  3. Click Create API Key to confirm.

You can now reveal and copy the Encryption key in its base64 representation for use on the backend.

🚧

The Encryption key should remain Inactive for now.

Step 2: Decrypt and validate the sealedResult payload on your backend

Once the payload arrives at your backend, it needs to be decrypted using the Encryption key generated in the previous step. The sealedResult payload (represented as base64 string) from the JS Agent has a format covered in Appendix: Payload format.

The easiest way to decrypt the sealed result is to use one of our Server SDKs.

const { unsealEventsResponse } = require('@fingerprintjs/fingerprintjs-pro-server-api');

async function main() {
  const sealedData = process.env.BASE64_SEALED_RESULT;
  const decryptionKey = process.env.BASE64_KEY;

  if (!sealedData || !decryptionKey) {
    console.error('Please set BASE64_KEY and BASE64_SEALED_RESULT environment variables');
    process.exit(1);
  }

  try {
    const unsealedData = await unsealEventsResponse(Buffer.from(sealedData, 'base64'), [
      {
        key: Buffer.from(decryptionKey, 'base64'),
        algorithm: 'aes-256-gcm',
      },
    ]);
    console.log(JSON.stringify(unsealedData, null, 2));
  } catch (e) {
    console.error(e);
    process.exit(1);
  }
}
<?php

use Fingerprint\ServerAPI\Sealed\DecryptionAlgorithm;
use Fingerprint\ServerAPI\Sealed\DecryptionKey;
use Fingerprint\ServerAPI\Sealed\Sealed;

require_once(__DIR__ . '/vendor/autoload.php');

$sealed_result = base64_decode($_ENV['BASE64_SEALED_RESULT']);
$sealed_key = base64_decode($_ENV['BASE64_KEY']);

try {
    $data = Sealed::unsealEventResponse($sealed_result, [new DecryptionKey($sealed_key, DecryptionAlgorithm::AES_256_GCM)]);

    fwrite(STDOUT, sprintf("Unsealed event: %s \n", $data));
} catch (Exception $e) {
    fwrite(STDERR, sprintf("Exception when unsealing event: %s\n", $e->getMessage()));
    exit(1);
}
package main

import (
	"encoding/base64"
	"fmt"
	"github.com/fingerprintjs/fingerprint-pro-server-api-go-sdk/v5/sdk/sealed"
	"os"
)

// Utility function to decode base64 string
func base64Decode(input string) []byte {
	output, err := base64.StdEncoding.DecodeString(input)
	if err != nil {
		panic(err)
	}
	return output
}

func main() {
	// Sealed result from the frontend.
	sealedResult := base64Decode(os.Getenv("BASE64_SEALED_RESULT"))
	// Base64 encoded key generated in the dashboard.
	key := base64Decode(os.Getenv("BASE64_KEY"))

	keys := []sealed.DecryptionKey{
		// You can provide more than one key to support key rotation. The SDK will try to decrypt the result with each key.
		{
			Key:       key,
			Algorithm: sealed.AlgorithmAES256GCM,
		},
	}
	unsealedResponse, err := sealed.UnsealEventsResponse(sealedResult, keys)

	if err != nil {
		panic(err)
	}

	// Do something with unsealed response, e.g: send it back to the frontend.
}
package com.fingerprint.example;

import com.fingerprint.Sealed;
import com.fingerprint.model.EventResponse;

import java.util.Base64;

public class SealedResults {
    public static void main(String... args) throws Exception {
        // Sealed result from the frontend.
        String SEALED_RESULT = System.getenv("BASE64_SEALED_RESULT");

        // Base64 encoded key generated in the dashboard.
        String SEALED_KEY = System.getenv("BASE64_KEY");

        final EventResponse event = Sealed.unsealEventResponse(
                Base64.getDecoder().decode(SEALED_RESULT),
                // You can provide more than one key to support key rotation. The SDK will try to decrypt the result with each key.
                new Sealed.DecryptionKey[]{
                        new Sealed.DecryptionKey(
                                Base64.getDecoder().decode(SEALED_KEY),
                                Sealed.DecryptionAlgorithm.AES_256_GCM
                        )
                }
        );

        // Do something with unsealed response, e.g: send it back to the frontend.
    }
}
using FingerprintPro.ServerSdk;

var sealedResult = Environment.GetEnvironmentVariable("BASE64_SEALED_RESULT")!;
var sealedKey = Environment.GetEnvironmentVariable("BASE64_KEY")!;

var events = Sealed.UnsealEventResponse(Convert.FromBase64String(sealedResult), new[]
{
    new Sealed.DecryptionKey(Convert.FromBase64String(sealedKey), Sealed.DecryptionAlgorithm.Aes256Gcm)
});

Console.WriteLine(events.ToJson());
import base64
import os

from dotenv import load_dotenv

from fingerprint_pro_server_api_sdk import EventResponse
from fingerprint_pro_server_api_sdk.sealed import unseal_events_response, DecryptionKey, DecryptionAlgorithm

load_dotenv()

sealed_result = base64.b64decode(os.environ["BASE64_SEALED_RESULT"])
key = base64.b64decode(os.environ["BASE64_KEY"])

try:
    events_response: EventResponse = unseal_events_response(sealed_result, [DecryptionKey(key, DecryptionAlgorithm['Aes256Gcm'])])
    print("\n\n\nEvent response: \n", events_response.products)
except Exception as e:
    print("Exception when calling unsealing events response: %s\n" % e)
    exit(1)

print("Unseal successful!")

exit(0)

If you prefer to write the decryption code yourself, see the Appendix at the end of this page for some examples. The SDKs are open-source so you can also refer to their source code on GitHub.

❗️

Never decrypt the payload in the browser

The decryption is designed to work on the backend only. If you try to decrypt the payload on the client, it means that anyone can see the shared key. That opens the possibility to read and alter the payload, exposing sensitive information and introducing new attack vectors.

Step 3: Send sealedResult to your backend

Adjust your client code to send the sealedResult received from the Fingerprint JavaScript agent to the server endpoint you have prepared in the previous step:

import * as FingerprintJS from '@fingerprintjs/fingerprintjs-pro'

const fpPromise = FingerprintJS.load({
  apiKey: "<<browserToken>>"
});

fpPromise
  .then(fp => fp.get())
  .then(fingerprint => {
    const sealed = fingerprint.sealedResult;
    fetch("https://your-backend.com/sealed", { "method": "POST", "body": sealed }) 
      .then(response => console.log("Finished with status: ", response.status))
  })
  .catch(err => console.error("Failed to complete Sealed Client Results flow with error: ", err));

📘

Server API Fallback

You may also consider sending the requestId that is returned in the regular JavaScript agent response to support the fallback to Server API request if something goes wrong and sealedResult is not available.

Step 4: Activate the encryption key in the Dashboard

Once the backend is set up to accept and decrypt incoming traffic, and the frontend contains code to send the sealedResult to the backend, you can activate your Encryption key.

  1. Navigate to App Settings > API Keys > Encryption Keys.
  2. Click ... and select Activate.

That completes the setup and the sealedResult payload should start flowing from your client to your backend. Note that the key propagation could take a few minutes to finish so don't worry if you don't see the sealedResult payload immediately after the key got activated.

🚧

When a key is activated, the JS agent removes everything except requestId and sealedResult from the payload. Make sure that there is nothing left on the client that depends on the original (unencrypted) payload before activating the first key.

Key Rotation

We recommend creating a regular process for key rotation to prevent potential misuse. The generated keys don't expire but it is advised to rotate the key every 3 months, or before reaching a total of 4 billion requests, or if the key might have leaked (whatever comes first).

In all cases, follow these steps:

  1. Generate a new Encryption key in the Dashboard. The process is identical to Step 1 from the initial configuration steps.
  2. Update the backend to try unsealing with both the old (original) and the new key.
  3. Activate the new key. The old key is automatically deactivated as there cannot be more than one key active at a time.
  4. Wait a few moments until your backend starts receiving results sealed with the new key exclusively. Optionally, you can then completely remove the old key from the backend logic.
  5. Once everything works as expected you can delete the old key through the Dashboard.

Replay attack protection

Sealed Client Results don't add any protection against possible replay attacks. However, the feature makes it easy to implement replay attack protection.

With Sealed Client Results, you only need to check if the requestId retrieved from the sealedResult (products.identification.data.requestId) contains a unique and previously unseen value. Because the sealed payload cannot be modified, the requestId is always valid and genuine.

Additionally, you can also check the timestamp field to further validate the authenticity of the request. The time difference between timestamp and the current time should not surpass a reasonably low threshold.

Disabling Sealed Client Results

It's not possible to delete the last encryption key through the Dashboard to prevent potential risks associated with removing the encrypted payload. If you wish to disable the feature completely, please contact our support team.

👍

Implementation example

See our VPN detection use case demo and tutorial for a practical example of Sealed results implementation. The demo is open-source and available on GitHub.

Appendix: Payload format

The sealedResult field is a base64 representation of the following binary structure:

Sealed Client Results Payload Format

Sealed Client Results Payload Format

  • [4 Bytes] Version header (currently set to 0x9E85DCED)
    • You can use the version header to check that you are processing the correct version of the payload but it isn't part of the encrypted section. In rare cases where we introduce breaking changes to the payload structure, the version header will also get a new value (along with a way to pick which version you want to consume to ensure a smooth transition between different versions).
  • /events Payload sealed with AES-256-GCM
    • [12 Bytes] Nonce/Initialization Vector
    • Encrypted /events JSON payload, compressed with raw deflate
    • [16 Bytes] Authentication Tag

See our reference implementation that unseals and decompresses the payload, while printing out debug output with intermediate steps:

import * as crypto from 'crypto'
import { promisify } from 'util'
import * as zlib from 'zlib'

const sealedResultBase64 = "noXc7SXO+mqeAGrvBMgObi/S0fXTpP3zupk8qFqsO/1zdtWCD169iLA3VkkZh9ICHpZ0oWRzqG0M9/TnCeKFohgBLqDp6O0zEfXOv6i5q++aucItznQdLwrKLP+O0blfb4dWVI8/aSbd4ELAZuJJxj9bCoVZ1vk+ShbUXCRZTD30OIEAr3eiG9aw00y1UZIqMgX6CkFlU9L9OnKLsNsyomPIaRHTmgVTI5kNhrnVNyNsnzt9rY7fUD52DQxJILVPrUJ1Q+qW7VyNslzGYBPG0DyYlKbRAomKJDQIkdj/Uwa6bhSTq4XYNVvbk5AJ/dGwvsVdOnkMT2Ipd67KwbKfw5bqQj/cw6bj8Cp2FD4Dy4Ud4daBpPRsCyxBM2jOjVz1B/lAyrOp8BweXOXYugwdPyEn38MBZ5oL4D38jIwR/QiVnMHpERh93jtgwh9Abza6i4/zZaDAbPhtZLXSM5ztdctv8bAb63CppLU541Kf4OaLO3QLvfLRXK2n8bwEwzVAqQ22dyzt6/vPiRbZ5akh8JB6QFXG0QJF9DejsIspKF3JvOKjG2edmC9o+GfL3hwDBiihYXCGY9lElZICAdt+7rZm5UxMx7STrVKy81xcvfaIp1BwGh/HyMsJnkE8IczzRFpLlHGYuNDxdLoBjiifrmHvOCUDcV8UvhSV+UAZtAVejdNGo5G/bz0NF21HUO4pVRPu6RqZIs/aX4hlm6iO/0Ru00ct8pfadUIgRcephTuFC2fHyZxNBC6NApRtLSNLfzYTTo/uSjgcu6rLWiNo5G7yfrM45RXjalFEFzk75Z/fu9lCJJa5uLFgDNKlU+IaFjArfXJCll3apbZp4/LNKiU35ZlB7ZmjDTrji1wLep8iRVVEGht/DW00MTok7Zn7Fv+MlxgWmbZB3BuezwTmXb/fNw=="
const sealedResult = Buffer.from(sealedResultBase64, 'base64')
const sealedHeader = Buffer.from('9E85DCED', 'hex')
const key = Buffer.from('p2PA7MGy5tx56cnyJaFZMr96BCFwZeHjZV2EqMvTq53=', 'base64')

if (sealedResult.subarray(0, sealedHeader.length).toString('hex') !== sealedHeader.toString('hex')) {
    process.stderr.write('Wrong header\n')
    process.exit(1)
}

const nonceLength = 12
const authTagLength = 16
const nonce = sealedResult.subarray(sealedHeader.length, sealedHeader.length + nonceLength)
const ciphertext = sealedResult.subarray(sealedHeader.length + nonceLength, -authTagLength)
const authTag = sealedResult.subarray(-authTagLength)

const decipher = crypto.createDecipheriv('aes-256-gcm', key, nonce).setAuthTag(authTag)
const compressed = Buffer.concat([decipher.update(ciphertext), decipher.final()])
process.stdout.write('Decrypted/compressed:\n')
process.stdout.write(compressed.toString('base64'))
process.stdout.write('\n')

const payload = await promisify(zlib.inflateRaw)(compressed)
process.stdout.write('\nDecompressed:\n')
process.stdout.write(payload)
process.stdout.write('\n')
package main

import (
    "bytes"
    "compress/flate"
    "crypto/aes"
    "crypto/cipher"
    "encoding/base64"
    "fmt"
    "io"
    "log"
    "net/http"

    "github.com/pkg/errors"
)

var (
    sealHeader = []byte{0x9E, 0x85, 0xDC, 0xED}
    ErrHeaderUnknown = errors.New("unknown header")
)

// The main function is not part of the unsealing process, it's a usage example
func main() {
    base64Decode := func(input string) []byte {
        output, err := base64.StdEncoding.DecodeString(input)
        if err != nil {
            panic(err)
        }
        return output
    }

    unsealResponse := func(w http.ResponseWriter, r *http.Request) {
        b, err := io.ReadAll(r.Body)
        if err != nil {
            panic(err)
        }

        sealedResult := base64Decode(string(b))

        // Encryption key generated in step 1
        key := base64Decode("<encryption key>")

        unsealed, err := Unseal(sealedResult , key)
        if err != nil {
            panic(err)
        }

        fmt.Println(string(unsealed))

        w.WriteHeader(200)
    }

    http.HandleFunc("/sealed", unsealResponse)

    log.Fatal(http.ListenAndServe(":3030", nil))
}

func Unseal(sealed, key []byte) ([]byte, error) {
    if !bytes.Equal(sealed[:len(sealHeader)], sealHeader) {
        return nil, ErrHeaderUnknown
    }

    compressed, err := decrypt(sealed[len(sealHeader):], key)
    if err != nil {
        return nil, errors.Wrap(err, "decrypt")
    }

    payload, err := decompress(compressed)
    if err != nil {
        return nil, errors.Wrap(err, "decompress")
    }

    return payload, nil
}

func decrypt(payload, key []byte) ([]byte, error) {
    block, err := aes.NewCipher(key)
    if err != nil {
        return nil, errors.Wrap(err, "new cipher")
    }

    aesgcm, err := cipher.NewGCM(block)
    if err != nil {
        return nil, errors.Wrap(err, "new GCM")
    }

    if len(payload) < aesgcm.NonceSize() {
        return nil, errors.New("nonce")
    }

    nonce, ciphertext := payload[:aesgcm.NonceSize()], payload[aesgcm.NonceSize():]
    plaintext, err := aesgcm.Open(nil, nonce, ciphertext, nil)
    if err != nil {
        return nil, errors.Wrap(err, "aesgcm open")
    }

    return plaintext, nil
}

func decompress(compressed []byte) ([]byte, error) {
    reader := flate.NewReader(bytes.NewReader(compressed))
    defer reader.Close()

    decompressed, err := io.ReadAll(reader)
    if err != nil {
        return nil, errors.Wrap(err, "inflated payload read all bytes")
    }

    return decompressed, nil
}