Auth.js makes sign-up and sign-in the same click. A user presses “Continue with Google,” and whether they already have an account is decided somewhere deep in the adapter. That convenience is exactly what makes trial abuse easy to miss: there is no separate signup form to guard, so the fifth Google account from the same laptop gets a fresh trial like the first.
The fix is to put a device check in the signIn callback, but only for people who do not exist yet. Get that distinction wrong and you lock paying customers out of their own accounts. This guide shows the full pattern for Auth.js v5 on the Next.js App Router, using @prynt/react on the client and @prynt/node on the server. If you have not added Prynt to a Next.js app before, start with the Next.js fingerprinting guide.
Why the signIn callback, and why it is tricky
callbacks.signIn runs before Auth.js creates a session, and before the adapter creates a user. Returning false stops the flow; returning a string redirects to that path. That makes it the right gate.
The difficulty is that the same callback runs for three different situations:
- A brand new person creating their first account.
- A repeat abuser creating another account on a device that already has some.
- An existing customer logging in, possibly on a shared family computer that also hosts a spouse’s account.
Only case 2 should be refused. Cases 1 and 3 must always pass. So the callback has to answer “does this user already exist?” before it ever looks at device history.
Step 1: send the requestId through a cookie
In an OAuth flow, the callback runs after a redirect to the provider and back, so there is no form body. A short-lived cookie set just before signIn() survives the round trip.
'use client';
import { signIn } from 'next-auth/react';
import { useVisitorData } from '@prynt/react';
export function GoogleButton() {
const { getData } = useVisitorData({ immediate: false });
async function start() {
const result = await getData({ tag: { action: 'signin' } });
if (result) {
document.cookie = `prynt_rid=${result.requestId}; Path=/; Max-Age=600; SameSite=Lax; Secure`;
}
await signIn('google');
}
return <button onClick={start}>Continue with Google</button>;
}
Wrap the app in <PryntProvider apiKey="pk_live_…" extendedResult> once, in your root layout. extendedResult asks the API to compute Smart Signals and a risk decision for each identification (on plans that include them); without it, decision is always allow and only the account count does the work. getData resolves to null on error, so a failure never blocks the button; the server treats a missing cookie as “unknown,” not as “guilty.”
Step 2: decide new vs returning in signIn
On the server, create one PryntServer and do the existence check first.
// auth.ts
import NextAuth from 'next-auth';
import Google from 'next-auth/providers/google';
import { PrismaAdapter } from '@auth/prisma-adapter';
import { cookies } from 'next/headers';
import { PryntServer } from '@prynt/node';
import { db } from '@/lib/db';
const prynt = new PryntServer({ secretKey: process.env.PRYNT_SECRET_KEY! });
const MAX_ACCOUNTS_PER_DEVICE = 1;
async function userExists(account: any, email?: string | null) {
if (account?.provider && account.providerAccountId) {
const linked = await db.account.findUnique({
where: { provider_providerAccountId: {
provider: account.provider, providerAccountId: account.providerAccountId } },
});
if (linked) return true;
}
return email ? Boolean(await db.user.findUnique({ where: { email } })) : false;
}
export const { handlers, auth, signIn, signOut } = NextAuth({
adapter: PrismaAdapter(db),
providers: [Google],
callbacks: {
async signIn({ user, account }) {
if (await userExists(account, user.email)) return true; // returning: never block
const rid = (await cookies()).get('prynt_rid')?.value;
if (!rid) return true; // flag later, don't block
try {
const event = await prynt.getEvent(rid);
if (event.linkedId) return '/signup-blocked'; // requestId reused
if (event.decision === 'block') return '/signup-blocked';
if (event.accountsOnDevice.count >= MAX_ACCOUNTS_PER_DEVICE) return '/signup-blocked';
} catch {
return true; // Prynt unreachable: fail open
}
return true;
},
},
events: {
async signIn({ user }) {
const jar = await cookies();
const rid = jar.get('prynt_rid')?.value;
if (!rid || !user.id) return;
try { await prynt.updateEvent(rid, { linkedId: user.id }); } catch {}
jar.delete('prynt_rid');
},
},
});
The Prisma shapes above match the standard Auth.js schema; adapt userExists to whatever your adapter stores. The important property is that it runs before the device lookup and short-circuits for anyone you already know.
Returning a path such as /signup-blocked is friendlier than false, which lands on the generic Auth.js error page. Keep the message neutral (“we couldn’t create an account from this device”) and offer a support contact. Telling the user how many accounts you counted only helps the next attempt.
Step 3: link on every sign-in, not just the first
Notice that the linking happens in events.signIn, which fires on every successful sign-in, new or returning. Auth.js also has events.createUser, which fires only when the adapter inserts a user. Linking in createUser alone is enough to make the next signup see this account. Linking on every sign-in is better, for two reasons:
- Existing customers become visible. Users who signed up before you added Prynt get linked to their devices the next time they log in, so a returning customer who later tries to spin up a second “new” account from the same machine is counted.
- Account-level signals work. Signals that look across devices for one account, such as device spread, need the account linked on login events too.
If you only want to link genuinely new users, events.signIn also receives isNewUser when an adapter is configured, and events.createUser({ user }) is the narrower hook.
Email magic links and credentials
For the email provider, signIn runs twice: once when the link is requested (with email.verificationRequest set to true) and once when it is clicked. Do the device check on the first call, when the person is still on your page and the cookie is fresh, and let the second pass. For credentials sign-up, you usually have your own registration route anyway; check there, the same way as in the Node verification guide.
What to do with borderline signups
Not every new account on a busy device is abuse. Shared household computers, school labs and an honest person who lost access to an old account all produce a second signup. Instead of a hard block at one, consider:
- Block when Prynt’s own
decisionisblock(automation, tampering, known-bad device). - Require verification (card, phone) before the trial unlocks when
accountsOnDevice.countreaches your limit. - Flag when the cookie is missing or the decision is
challenge.
That is the same allow / flag / verify / block ladder the ready-made recipes for Clerk, Supabase and Auth0 use, with a default of blocking at two other accounts. The trial farming guide goes deeper into choosing the step-up, and OAuth adds its own takeover risks covered in social login takeover detection.
Checklist before shipping
- The existence check runs before the device check, and returns
truefor known users. - A missing cookie or a Prynt error never blocks sign-in.
- The cookie is short-lived and deleted after linking.
- The
linkedIdis your internal user id, not the email address. - You tested with a test-environment key, which only sees its own events.
Wire it up in a branch, sign up twice from one browser with two Google accounts, and confirm the second lands on your blocked page while the first still signs in normally. Field references are in the docs.
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.