Clerk gives you a polished sign-up flow, allowlists, disposable-email blocking and a bot CAPTCHA. What none of those can see is that three “different” sign-ups came from the same laptop. If your free trial hands out credits, seats or compute, that gap is where the cost leaks.
This post walks through the ready-made Clerk recipe in the Prynt repository (integrations/clerk-nextjs). It targets the Next.js App Router and adds one question to your sign-up: how many accounts has this device already opened?
The constraint: Clerk has no “before create” hook
With Supabase you get a Before User Created hook. With Auth0 you get Pre User Registration. Clerk has neither. Its webhooks are delivered asynchronously after the user exists. So the recipe uses two layers, and the distinction matters:
| Layer | Runs | Can it be skipped? |
|---|---|---|
Pre-check route app/api/prynt/precheck | Before signUp.password() | Yes, by calling Clerk’s Frontend API directly |
user.created webhook app/api/webhooks/clerk | Seconds after the user exists | No, every sign-up produces one |
The pre-check is for user experience: an honest repeat sign-up sees an error on the form, never gets a half-made account, and you never send a wasted verification email. The webhook is the actual enforcement. Run both. If you can only run one, run the webhook.
Step 1: identify on the sign-up page
The page loads the agent from the CDN path the Prynt API serves and identifies once per page view, as soon as the page renders, so the requestId is ready by the time the user hits submit.
'use client';
const ENDPOINT = 'https://api.pryntid.com';
let pending: Promise<string | null> | null = null;
export function identifyForSignup(): Promise<string | null> {
if (!pending) {
pending = (async () => {
const w = window as any;
if (!w.Prynt) await loadScript(`${ENDPOINT}/cdn/prynt.umd.js`); // appends a <script> tag
const agent = await w.Prynt.load({ apiKey: process.env.NEXT_PUBLIC_PRYNT_PUBLIC_KEY! });
const { requestId } = await agent.identify({ tag: { action: 'signup' } });
return requestId || null;
})().catch(() => null);
}
return pending;
}
Note the .catch(() => null). If the script is blocked, the page does not break. It sends null and lets the server decide.
Step 2: the pre-check route
On submit, the form posts the requestId to your own route before touching Clerk:
const requestId = await identifyForSignup();
const pre = await fetch('/api/prynt/precheck', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ requestId }),
}).then((r) => r.json()).catch(() => ({ allowed: true }));
if (!pre.allowed) { setMessage(pre.message); return; }
await signUp.password({
emailAddress: email,
password,
unsafeMetadata: { pryntRequestId: requestId },
});
Two details are deliberate. If your own pre-check route is down, the form proceeds and the webhook decides. And the requestId rides along in unsafeMetadata, which Clerk copies onto the user, so the webhook can re-check the same identification later.
The route itself calls GET /v1/events/{requestId} with your secret key and reads accountsOnDevice: every linkedId you have attached to that device, with count, firstSeenAt and lastSeenAt for each. It returns only { allowed, action, message } to the browser. Reason codes stay server-side, because telling an abuser which rule fired helps them tune around it.
Step 3: the user.created webhook
In the Clerk Dashboard, add a webhook endpoint pointing at /api/webhooks/clerk, subscribe it to user.created, and put the signing secret in CLERK_WEBHOOK_SIGNING_SECRET. The handler verifies the Svix signature with Clerk’s verifyWebhook, then:
- Reads
pryntRequestIdfrom the user’sunsafe_metadata(falling back tousers.getUser()if the payload lacks it). - Re-runs the same policy, this time excluding the new user from its own count.
- Attaches the user id to the device with
PUT /v1/events/{requestId}and{ "linkedId": user.id }, so the next sign-up from that device sees it. - Writes the verdict to metadata and bans the user if the verdict is
block.
Linking happens before banning on purpose: even a banned account should count against the device next time. Banning revokes sessions and blocks sign-in, and it is reversible with unbanUser. Set PRYNT_CLERK_BLOCK_MODE=delete if you would rather remove the user.
If a Clerk API call fails, the handler returns a 500 so Svix retries. The enforcement is idempotent, so retries are safe.
Each user ends up with publicMetadata.prynt = { action }, readable in your app and session claims, and privateMetadata.prynt with the full verdict: reasons, visitorId, requestId, accountsOnDevice, riskScore and a timestamp.
The shared policy: allow, flag, verify, block
The Clerk recipe runs the same policy module as the Express, Supabase and Auth0 recipes, so a threshold means the same thing everywhere. Each rule produces one action, and the strictest wins.
| Situation | Default action |
|---|---|
| Device already has 2 or more other accounts | block |
Prynt decision is block | block |
Prynt decision is challenge | flag |
No, unknown or stale requestId | flag |
Same requestId reused for another account | block |
| Prynt unreachable | allow (fail open) |
Every action is configurable through environment variables such as PRYNT_MAX_ACCOUNTS_PER_DEVICE and PRYNT_ON_LIMIT. Choosing the right one is a product decision:
- block fits when every extra account costs real money, such as free GPU or LLM credits.
- verify creates the account with
publicMetadata.prynt.action === 'verify'. Gate the trial on it in your app, for example by asking for a phone number or a card before credits unlock. This is the best default for most B2C SaaS, because a shared family or office machine does not lose sign-up entirely. - flag changes nothing visible. Start here to measure how much abuse you actually have before enforcing anything.
What to do when requestId is missing
A missing requestId is not proof of abuse. It happens when an ad blocker stops the script, when the visitor sends Global Privacy Control (the agent honors GPC by default and returns an empty requestId), or when you create users from the Dashboard or Backend API. That is why the default is flag, not block.
What you should watch is the rate. If flagged-for-missing accounts suddenly spike, someone is likely posting straight to Clerk’s Frontend API. At that point you can tighten PRYNT_ON_MISSING to verify, which keeps real privacy-conscious users able to sign up while making scripted farms do per-account work.
The pre-check also enforces freshness. By default an identification older than 900 seconds is treated as stale, which stops someone from harvesting one clean requestId and replaying it. The webhook tolerates at least 24 hours, because email verification sits between identify and user.created.
Trying it locally
Copy .env.example to .env.local, fill in your Clerk keys plus NEXT_PUBLIC_PRYNT_PUBLIC_KEY and PRYNT_SECRET_KEY, expose port 3000 with a tunnel for the webhook, and sign up from one browser with three inboxes. The first two go through; the third is refused on the form. If you bypass the form, the webhook bans the user.
The recipe targets @clerk/nextjs v7 and Next.js 16, where middleware lives in proxy.ts; on Next 15 and earlier, rename it to middleware.ts. For a general Next.js integration outside Clerk, see device fingerprinting in Next.js, and for choosing your limit, device-based sign-up limits.
Start in flag mode for a week, read the verdicts in privateMetadata, then move the expensive cases to verify or block. The background on why email checks alone fail is in stopping free-trial abuse, and the free plan on the pricing page covers 20k identifications a month while you measure.
Try it free
Prynt is device intelligence with a free tier — visitor IDs, bot & fraud Smart Signals, and behavioral biometrics, powered by a cross-site network. Start free.