Protect a Next.js sign-up from repeat free trials
Updated
Protect a Next.js sign-up in three steps. A route handler creates a short-lived intent with your secret key. A client component identifies the device with the intent and sends the opaque attestation with the sign-up form. A second route handler asks FingerPrint Checker for a decision and creates the account only when the answer is allow. The decision is made on the server; the browser never sees a score.
What you build
A sign-up form whose server side refuses a second free trial from the same device, and asks for a human check when the evidence is thin. It uses the App Router, the React package for the browser and the Node.js package for the server.
- GET /api/intent: creates an intent for this sign-up with your secret key.
- A client component: identifies the device and posts the attestation with the form.
- POST /api/signup: asks for the decision and creates the account only on allow.
Install
Add the React package for the page and the Node.js package for the route handlers. Create a project in the console first: you need the publishable key (pk_), which is safe in the browser, and the secret key (sk_), which is server only.
npm i @fingerprintchecker/agent @fingerprintchecker/react
npm i @fingerprintchecker/nodeStep 1: create the intent on the server
Creating the intent is a POST to /v2/intents, which the Node.js package wraps as createIntent. It binds one identify to your project and one purpose. Pass the visitor’s IP address from your own edge, never from a field the browser sends. Keep FPCHECKER_SECRET_KEY out of NEXT_PUBLIC variables.
// app/api/intent/route.ts
import { FpChecker } from '@fingerprintchecker/node';
const fp = new FpChecker({ secretKey: process.env.FPCHECKER_SECRET_KEY! });
export async function GET(request: Request) {
const clientIp = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim();
const res = await fp.createIntent({ purpose: 'claim:trial', clientIp });
if (res.ok !== true) return Response.json({ error: 'intent_failed' }, { status: 502 });
return Response.json({ intentToken: res.intentToken });
}Step 2: identify in a client component
Wrap the app once with the provider, then call identify from the submit handler, not during render. Nothing loads during server rendering. Send the attestation to your own route together with the form.
// app/fp-provider.tsx
'use client';
import { FpCheckerProvider } from '@fingerprintchecker/react';
export function Providers({ children }: { children: React.ReactNode }) {
return <FpCheckerProvider publishableKey={process.env.NEXT_PUBLIC_FPCHECKER_KEY!}>{children}</FpCheckerProvider>;
}
// app/signup/signup-form.tsx
'use client';
import { useFpChecker } from '@fingerprintchecker/react';
export function SignupForm() {
const { identify, isLoading, error } = useFpChecker();
async function onSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
const email = String(new FormData(event.currentTarget).get('email'));
const { intentToken } = await (await fetch('/api/intent')).json();
const result = await identify({ intentToken });
const res = await fetch('/api/signup', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ email, attestation: result.attestation }),
});
// Show the message your route returns; it only ever says whether the sign-up worked.
}
return (
<form onSubmit={onSubmit}>
<input name="email" type="email" required />
<button disabled={isLoading}>Start free trial</button>
{error && <p role="alert">{error.message}</p>}
</form>
);
}Step 3: decide on the server
Send the attestation to POST /v1/eligibility (checkEligibility) with the email and claim: true, so an allowed sign-up is recorded as the device’s claim. A refusal is a normal 2xx answer with eligible: false and a reason, so read the body. The SDK throws only for a network failure, a 5xx or a 400 for a bad request such as a missing attestation. Grant only when eligible is exactly true, and treat anything thrown as “not granted, try again”: fail closed, never open.
// app/api/signup/route.ts
import { FpChecker, FpCheckerApiError } from '@fingerprintchecker/node';
const fp = new FpChecker({ secretKey: process.env.FPCHECKER_SECRET_KEY! });
export async function POST(request: Request) {
const { email, attestation } = await request.json();
const clientIp = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim();
let verdict;
try {
verdict = await fp.checkEligibility({ attestation, email, claim: true, clientIp });
} catch (err) {
// Thrown: network failure, 5xx or 400 (bad request). Not a decision, so ask the user to retry.
if (err instanceof FpCheckerApiError) return Response.json({ ok: false, error: 'try_again' }, { status: 503 });
throw err;
}
if (verdict.eligible === true && verdict.action === 'allow') {
await createAccountWithTrial(email); // your code
return Response.json({ ok: true });
}
// A normal 2xx refusal: action is 'pow' or 'turnstile' when a human check is needed, 'deny' (with verdict.reason) otherwise.
return Response.json({ ok: false, action: verdict.action }, { status: 403 });
}Before you ship
- Add your site’s origin to the project’s allowed origins, or the browser call is refused.
- Return only the decision to the browser. visitorId and trust score stay on your server.
- Take the IP address from your own edge or proxy, and the user id (if you pass a subject) from your session.
- Test with a second browser on the same computer: it should be refused after the first claim.
Frequently asked questions
Do I need a seal key in the browser?
No. The intent flow has no seal key in the client. The browser only holds the publishable key and a short-lived intent token.
Can I use this with the Pages Router or another framework?
Yes. The server half is plain code in any route or handler, and the browser half works in any React app. Vue and Nuxt have their own package.
What if the API is unreachable?
Treat it as a refusal and ask the user to try again. Granting the trial when the check fails would let anyone skip the check by making it fail.
Try FingerPrint Checker free
Every new account gets 2,000 free checks. No card required to start.
Create a free account