To add device fingerprinting to a signup form you need three pieces: a browser snippet that mints a short-lived device token, a server call that forwards the token with the rest of the signup, and a check that proves the token landed. With all three, two accounts created on the same machine link to each other even when the emails, the IPs and the passwords are all different.
The part worth getting right is the trust boundary. The browser collects; the server decides; nothing the browser says about itself is believed on its own.
Prerequisites
- Next.js App Router (the shapes here are the same in any React app with a JSON endpoint), Node 18.17 or newer.
- A Portreeve account with a test secret key (
sk_test_...) and a test publishable key (pk_test_...). The free tier is 1,000 screened events a month, no card. - A signup endpoint that currently creates a user row. You will add one call in front of it.
- Somewhere to store the screening event id per signup. A column is fine.
Why a device token, not a fingerprint hash
The obvious design is to hash a few dozen browser properties in the page and post the hash to your server. Do not do that.
A value computed in the page is a value the page can lie about. The FingerprintJS README says it plainly: because fingerprints are generated and processed in the browser, they "are vulnerable to spoofing and reverse engineering". Anyone farming free trials will open devtools, find your visitorId, and rotate it per account in about ten minutes. You end up with a fingerprinting system that produces a fresh device for every abusive signup and one stable device per honest user, which is exactly backwards.
Fingerprint's own guidance on protecting from client-side tampering and replay attacks points the same way: send an opaque reference from the client, resolve it server to server, and check freshness so an intercepted value cannot be replayed later.
The token model does that in one step. collectDeviceToken() gathers the signals and returns a signed, short-lived blob. Your server never parses it. It forwards the blob with the verdict call, and the token is verified and unpacked on the other side, where the browser cannot reach it. If the token is missing, forged or stale, it is dropped and the verdict still returns; you lose a signal, not the signup.
The token lives 15 minutes. Short expiry is normal for this class of credential, and for the same reason a Cloudflare Turnstile response token is valid for 300 seconds: a value harvested from a real browser is worth much less if it goes stale before it can be resold. That expiry is also the most common way this integration breaks, so step 1 is mostly about when you call it.
1. Add the browser fingerprint snippet to your signup form
npm install @portreeve/browser portreeve
The publishable key is meant to be in the bundle. The secret key is not:
NEXT_PUBLIC_PORTREEVE_PUBLISHABLE_KEY=pk_test_...
PORTREEVE_SECRET_KEY=sk_test_...
Now the timing. Collect the token in the submit handler, not on mount:
// app/signup/signup-form.tsx
"use client";
import { useState } from "react";
import { collectDeviceToken } from "@portreeve/browser";
export function SignupForm() {
const [busy, setBusy] = useState(false);
async function onSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setBusy(true);
const form = new FormData(e.currentTarget);
// Collected here, at submit: the token is valid for 15 minutes.
const device_token = await collectDeviceToken(
process.env.NEXT_PUBLIC_PORTREEVE_PUBLISHABLE_KEY!,
);
const res = await fetch("/api/signup", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
email: form.get("email"),
password: form.get("password"),
device_token,
}),
});
setBusy(false);
// ...handle res
}
return <form onSubmit={onSubmit}>{/* fields */}</form>;
}
There is no try/catch because collectDeviceToken never throws. It resolves to a token string or to null, and null is a legitimate outcome you pass through untouched. Do not substitute an empty string, and do not fall back to a hash you rolled yourself.
What to expect when it works: the promise resolves before your fetch fires, the network tab shows one extra request ahead of /api/signup, and console.log(device_token) prints an opaque string. The device fingerprinting integration guide covers the other placements once signup works.
Collecting at submit costs one round trip at the only moment it buys anything. It also means a user who leaves the tab open through lunch still submits a fresh token.
2. Pass the token through on the server
The server call is where the decision happens. The token rides along as one more field:
// app/api/signup/route.ts
import { NextResponse } from "next/server";
import { Portreeve } from "portreeve";
const portreeve = new Portreeve(process.env.PORTREEVE_SECRET_KEY!);
export async function POST(req: Request) {
const { email, password, device_token } = await req.json();
const ip = req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? "";
const result = await portreeve.verdict({
event_type: "signup",
ip,
email,
device_token: typeof device_token === "string" ? device_token : undefined,
dedupeKey: `signup:${email}`,
});
if (result.verdict === "block") {
return NextResponse.json({ error: "Unable to create account" }, { status: 400 });
}
const user = await createUser({ email, password });
return NextResponse.json({ ok: true, eventId: result.id });
}
Two things in there are deliberate.
device_token is type-checked and otherwise left undefined. You are forwarding a blob, not validating it; the guard exists so a client that posts { device_token: { evil: true } } does not turn into a serialization surprise.
dedupeKey covers the double-submit. One logical signup should produce one verdict, and a repeat with the same key returns the original verdict rather than a 409.
One call belongs after the code above, once your user row exists. portreeve.attachUser(result.id, user.id) links the screening event to the id that did not exist when you screened, which is what keeps later feedback and the identity graph pointed at your own primary key. Store result.id alongside the user either way.
The IP line deserves a caveat. x-forwarded-for is trustworthy only if a proxy you control writes it, and the leftmost entry is client-supplied on a bare Node server. If you sit behind your own load balancer rather than Vercel, count hops properly first, which the Express middleware walkthrough covers in detail. IP is a soft signal here anyway. It never links two accounts on its own, because shared egress makes per-IP counts untrustworthy, and the device key is the one doing the linking.
What to expect when it works: result.verdict is "allow", result.reasons is an array, result.degraded is false, and the verdict comes back in under 100 ms. If degraded is true with a *_failopen reason code, the check timed out and the SDK returned allow on purpose. That is the default: on an outage the signup form stays up.
3. Prove the token landed
This is the step people skip, and it is why half of these integrations quietly do nothing. A bad, expired or mismatched token is silently dropped and the verdict still returns 200. Success and failure look identical from the outside unless you go looking.
The check is a negative control. In test mode, from one browser profile:
- Sign up with
[email protected]. Logresult.idandresult.reasons. - Without clearing anything, sign up again with
[email protected]. Log both again.
On the second signup you should see a device-keyed entry in result.reasons, a velocity or linked-account signal that can only exist if both events resolved to the same device key. The reason codes reference lists which codes those are.
Now break it on purpose. Comment out the device_token line in the fetch body and run the same two signups with fresh emails. The device-keyed reasons disappear and the events stop linking. That difference is your proof, and it takes two minutes. Test mode is fully isolated from live, so none of this touches your production identity graph.
Keep the check. A one-line assertion in an integration test that the second signup carries a device-keyed reason will catch the day someone refactors the form and drops the field.
Why a fingerprint API integration fails silently
Silent dropping is the right behaviour: a fingerprinting failure must never take down a signup form. It does mean you diagnose by absence. Three causes, in order of how often they happen.
Expired. The token is more than 15 minutes old. This is what you get when collectDeviceToken runs in a useEffect on page load and the user reads your pricing page first, and it is close to guaranteed on a multi-step signup where step one paints the form and step four submits it. Tell: device-keyed reasons appear for fast test submits and vanish for slow real ones. Fix: move the call into the submit handler, or re-collect on the final step of a wizard.
Mode mismatch. A pk_test_... token sent with an sk_live_... verdict, or the reverse. Staging builds that inherit production secrets are the usual culprit, as is a .env.local that survived a deploy. Test and live are fully isolated, so the token resolves to nothing and is discarded. Tell: device-keyed reasons work locally and never in staging. Fix: log both key prefixes on boot and assert they match.
Re-encoded. The token is a signed string and any mutation invalidates the signature. It gets mangled by a form library that trims whitespace, a URL-encoded body that eats a character, a length-capped database column it passed through on the way, or an analytics wrapper that JSON-stringifies twice. Tell: compare the string length in the browser console against the length your handler receives; if they differ, something in between is editing it. Fix: send it in a JSON body, untouched, and do not store it.
A fourth case is not a bug. collectDeviceToken resolves to null for some users: hardened browsers, blocked scripts, and privacy tooling that randomizes canvas and audio output all produce that. Design for a population where the device signal is present most of the time and absent for a real minority. The fuller account of what fingerprinting can and cannot do covers which browsers break it and how.
Privacy and disclosure
Fingerprinting is in scope of the EU cookie rules. The EDPB's Guidelines 2/2023 on the technical scope of Article 5(3) of the ePrivacy Directive extend that article beyond cookies to tracking pixels, tracking links and device fingerprinting, on the reasoning that reading device characteristics is gaining access to information stored in terminal equipment. Fraud prevention is a much better footing than advertising is, but the practice belongs in your privacy policy in plain words: what you collect at signup, why, and how long you keep it.
Two implementation details make that easier to write honestly. Identity keys are hashed from day one, so what is stored is a one-way hash of the device key rather than the browser properties themselves, and raw event payloads are scrubbed to those hashes after 90 days. The token model also keeps the signals out of your own database entirely; you never hold a fingerprint you would have to explain.
Use the device signal for abuse decisions only. The moment it feeds personalization or ad targeting you are in a different regulatory conversation with a much worse answer.
The mistakes people make
Collecting on page load. The most common failure by a distance. A useEffect that mints a token and stashes it in state gives you a token that is stale by the time it matters.
Requiring the token. Rejecting signups where device_token is null locks out Tor users, privacy-hardened Firefox, corporate browsers with locked-down script policies, and anyone whose network hiccuped. Treat it as a signal, not a gate. A missing device key should make the rest of the evidence matter more, not end the request.
Mixing test and live keys. The publishable key lives in your client bundle and the secret key in your server environment. They are set in different places by different pipelines and they drift. Assert the pair at startup.
Trusting a device key as an identity. It is not one. Identical hardware on the same OS and browser build can collide into one key, and keys drift: asked on the FingerprintJS tracker whether a visitorId can change over time, maintainer @makma answered that "fingerprints generated by open-source FingerprintJS might change within weeks." A device key earns its keep by linking accounts inside a cluster. It is one hard key among several: email, card fingerprint, phone, payer wallet.
Stopping at signup. The device counter is read at checkout too, and two card-testing rules are only block-strength when a device key is present. Without the snippet on your payment page, those shapes top out at review. If card testing is your actual problem, put collectDeviceToken on the checkout form as well.
Going to production
Swap pk_test_/sk_test_ for the live pair, then re-run the two-signup check against live with throwaway emails so you know the device key resolves in the real environment. Keep the fail-open default unless you have a specific reason not to. Add the snippet to your other high-value forms.
Then leave the negative control in your test suite. The failure mode of this integration is silent: a form that has been posting null for three weeks while the dashboard looks fine.
You can wire the whole thing up on the free tier, 1,000 screened events a month with no card and test mode isolated from live. Create a key and run the two-signup check.