All articles Integration

Better Auth: Adding a Device Check to Email and Social Sign-Up

Better Auth gives you sign-up for email and password and for a long list of OAuth providers in a few lines of config. That convenience works for abusers too. A free tier or trial protected only by “one account per email” is easy to farm with plus-addressed Gmail or a fresh GitHub account.

This guide adds a device check to both paths using Better Auth’s own hook system. The flow is the standard device-based signup limit: identify the browser, verify the requestId on the server, refuse devices that already hold accounts, and attach the new user id afterwards.

Two sign-up paths, two hook points

Better Auth creates users in two different places:

  • Email and password: POST /sign-up/email. The request carries a body and headers you control, so a hooks.before middleware can reject it before the password is even hashed.
  • OAuth / social: the user is created inside the provider callback (/callback/:id) after a redirect round-trip. No sign-up endpoint runs, and you don’t control that request’s body.

The one place both paths share is the database write. databaseHooks.user.create.before runs for every new user, however they arrived. The code below uses the endpoint hook for a fast, clear rejection on email sign-ups and the database hook as the catch-all.

Capture the requestId in the browser

// lib/prynt-client.ts
const ready = (async () => {
  const agent = await (window as any).Prynt.load({ apiKey: 'pk_live_…' });
  return agent.identify({ tag: { action: 'signup' } });
})().catch(() => null);

export async function pryntRequestId(): Promise<string | null> {
  const r = await ready;
  return r?.requestId || null;
}

Load the agent with <script src="https://api.pryntid.com/cdn/prynt.umd.js"> in your layout, or serve it from your own domain so content blockers are less likely to strip it.

For email sign-up, send the id as a header:

const rid = await pryntRequestId();
await authClient.signUp.email(
  { email, password, name },
  { headers: rid ? { 'x-prynt-request-id': rid } : {} },
);

For social sign-up, write it to a short-lived cookie before the redirect:

const rid = await pryntRequestId();
if (rid) document.cookie = `prynt_rid=${rid}; Max-Age=900; Path=/; SameSite=Lax; Secure`;
await authClient.signIn.social({ provider: 'github' });

SameSite=Lax cookies are sent on the top-level GET navigation back from the provider, so the cookie is there when the callback runs. Fifteen minutes matches the default replay window the Prynt recipes use for stale identifications.

The server-side check

One helper does the Prynt call with your secret key and returns a verdict:

// lib/prynt-check.ts
const LIMIT = 2; // accounts already on the device that trip the check

export async function checkDevice(requestId: string | null) {
  if (!requestId) return { action: 'flag' as const, reason: 'missing_request_id' };
  try {
    const res = await fetch(`https://api.pryntid.com/v1/events/${encodeURIComponent(requestId)}`, {
      headers: { Authorization: `Bearer ${process.env.PRYNT_SECRET_KEY}` },
      signal: AbortSignal.timeout(3000),
    });
    if (res.status === 404) return { action: 'flag' as const, reason: 'event_not_found' };
    if (!res.ok) return { action: 'allow' as const, reason: 'prynt_unavailable' };
    const event = await res.json();
    if (event.decision === 'block') return { action: 'block' as const, reason: 'prynt_block' };
    if ((event.accountsOnDevice?.count ?? 0) >= LIMIT) {
      return { action: 'block' as const, reason: 'device_account_limit' };
    }
    if (event.linkedId) return { action: 'block' as const, reason: 'request_id_reused' };
    return { action: event.decision === 'challenge' ? 'flag' as const : 'allow' as const };
  } catch {
    return { action: 'allow' as const, reason: 'prynt_unavailable' }; // fail open
  }
}

The defaults mirror the shared policy in Prynt’s other recipes: block on Prynt’s block and at two other accounts on the device, flag on challenge and on a missing id, fail open on an outage. The linkedId check catches a single clean identification being replayed across several sign-ups.

Wiring the hooks

// auth.ts
import { betterAuth } from 'better-auth';
import { createAuthMiddleware, APIError } from 'better-auth/api';
import { checkDevice } from './lib/prynt-check';

const MESSAGE = "We couldn't create an account from this device. Contact support if this is a mistake.";

function readRequestId(ctx: any): string | null {
  const h = ctx?.headers ?? ctx?.request?.headers;
  const fromHeader = h?.get?.('x-prynt-request-id');
  if (fromHeader) return fromHeader;
  const cookie = h?.get?.('cookie') ?? '';
  const m = cookie.match(/(?:^|;\s*)prynt_rid=([A-Za-z0-9_-]+)/);
  return m ? m[1] : null;
}

export const auth = betterAuth({
  // database, emailAndPassword, socialProviders …
  user: {
    additionalFields: {
      pryntRequestId: { type: 'string', required: false, input: false },
      pryntFlag: { type: 'string', required: false, input: false },
    },
  },
  hooks: {
    before: createAuthMiddleware(async (ctx) => {
      if (ctx.path !== '/sign-up/email') return;
      const verdict = await checkDevice(readRequestId(ctx));
      if (verdict.action === 'block') throw new APIError('FORBIDDEN', { message: MESSAGE });
    }),
  },
  databaseHooks: {
    user: {
      create: {
        before: async (user, ctx) => {
          const rid = readRequestId(ctx);
          const verdict = await checkDevice(rid);
          if (verdict.action === 'block') throw new APIError('FORBIDDEN', { message: MESSAGE });
          return {
            data: {
              ...user,
              pryntRequestId: rid,
              pryntFlag: verdict.action === 'flag' ? verdict.reason ?? 'challenge' : null,
            },
          };
        },
        after: async (user: any) => {
          if (!user.pryntRequestId) return;
          await fetch(`https://api.pryntid.com/v1/events/${user.pryntRequestId}`, {
            method: 'PUT',
            headers: {
              Authorization: `Bearer ${process.env.PRYNT_SECRET_KEY}`,
              'Content-Type': 'application/json',
            },
            body: JSON.stringify({ linkedId: user.id }),
          }).catch(() => {}); // linking is best effort; log it in production
        },
      },
    },
  },
});

A few details matter here.

  • input: false on the extra fields means a client can’t set pryntRequestId or pryntFlag itself. Only your hook writes them.
  • The email path runs the check twice, once in the endpoint hook and once in the database hook. If you’d rather make one call, drop the endpoint hook and accept that the rejection happens a little later in the request.
  • The after hook does the linking. That PUT /v1/events/{requestId} with linkedId is what makes the next sign-up from this device see this user in accountsOnDevice. Without it, the limit never trips.
  • ctx can be missing when a user is created outside a request, for example by an admin script. readRequestId then returns null and the user is flagged instead of blocked.

OAuth needs its own care

Social sign-up is where farming usually moves once email sign-up is guarded. GitHub, Google and Discord accounts are cheap to create, and to your product each one looks like a verified identity. The device check doesn’t care which provider vouched for the account. What it counts is how many of your accounts already sit on this device, which is why the multi-accounting signal holds up across email and OAuth alike.

Note that the database hook fires only for new users. A returning user signing in with Google doesn’t run it. That’s what you want: the gate sits at account creation, not at every login.

Handling flagged accounts

pryntFlag gives you a quiet review queue. Common uses:

  • Withhold trial credits until a flagged user verifies a phone number.
  • Exclude flagged accounts from referral payouts until they convert.
  • Read the flagged list weekly for a month and decide whether the limit should move.

Keep the user-facing message neutral and include a support path. Shared family laptops and office machines do hit device limits, and a real person should be able to get through.

Wrap-up

Use the endpoint hook to reject email sign-ups early, the database hook to cover OAuth, and the after hook to link every new user. For the full list of event fields, see the Node verification guide and 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.

Keep reading