Integrations

How to block fake signups in Better Auth

Better Auth lets plugins run code right before it creates a user. The SignupVet plugin uses that hook to check the email, phone and IP of every new signup, stops clear fakes and saves the risk on the user. It is one file to copy, with no extra dependencies.

Before you start

  1. Create a free account at signupvet.com/signup (250 checks a month, no card) and confirm your email.
  2. Create an API key in the dashboard.
  3. Add it to your server environment, never to browser code: SIGNUP_API_KEY=sv_live_...

1. Copy the plugin into your project

Save this file, for example as lib/signupvet.ts. It calls the API with a 2 second timeout and fails open: if the API is slow, cannot be reached or your monthly checks are used up, the signup goes through.

lib/signupvet.ts

// signupvet.ts: SignupVet plugin for Better Auth (copy this file into your project).
//
// Checks the email, phone and IP of every new user before Better Auth creates it.
// "block" stops the signup, everything else is saved on the user as signupRisk and
// signupRiskScore (read-only for users). Fails open: if the API is slow, unreachable
// or your monthly checks are used up, the signup goes through.
import type { BetterAuthPlugin } from "better-auth";
import { APIError, getIP } from "better-auth/api";

export type SignupCheck = {
  recommendation: "allow" | "review" | "block";
  risk_score: number | null;
  reason_codes: string[];
  failed_open?: boolean;
};

export type SignupVetOptions = {
  /** Your sv_live_... key. Defaults to process.env.SIGNUP_API_KEY. */
  apiKey?: string;
  /** Recommendations that stop the signup. Default: ["block"]. */
  blockOn?: SignupCheck["recommendation"][];
  /** Message shown when a signup is blocked. Keep it neutral. */
  blockedMessage?: string;
  /** Milliseconds to wait for the API before letting the signup through. Default: 2000. */
  timeoutMs?: number;
  /** Called with every result, for example to log it. */
  onResult?: (result: SignupCheck, user: { email: string }) => void | Promise<void>;
  baseUrl?: string;
};

const FAIL_OPEN: SignupCheck = { recommendation: "allow", risk_score: null, reason_codes: [], failed_open: true };
const LOOPBACK = new Set(["127.0.0.1", "::1"]);

export async function checkSignup(
  apiKey: string | undefined,
  signup: { email?: string; phone?: string; ip?: string; userAgent?: string },
  { baseUrl = "https://www.signupvet.com", timeoutMs = 2000 } = {},
): Promise<SignupCheck> {
  if (!apiKey) return FAIL_OPEN;
  try {
    const res = await fetch(`${baseUrl}/score`, {
      method: "POST",
      headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json" },
      body: JSON.stringify({
        email: signup.email,
        phone: signup.phone,
        ip: signup.ip,
        context: { user_agent: signup.userAgent },
      }),
      signal: AbortSignal.timeout(timeoutMs),
    });
    return res.ok ? ((await res.json()) as SignupCheck) : FAIL_OPEN;
  } catch {
    return FAIL_OPEN;
  }
}

export const signupVet = (options: SignupVetOptions = {}) => {
  const blockOn = options.blockOn ?? ["block"];
  return {
    id: "signupvet",
    schema: {
      user: {
        fields: {
          signupRisk: { type: "string", required: false, input: false },
          signupRiskScore: { type: "number", required: false, input: false },
        },
      },
    },
    init(ctx) {
      return {
        options: {
          databaseHooks: {
            user: {
              create: {
                async before(user, context) {
                  // No request (for example a seed script) or a guest account: nothing to check.
                  if (!context || context.path === "/sign-in/anonymous") return;
                  const headers = context.request?.headers ?? context.headers;
                  const ip = headers ? getIP(headers, ctx.options) : null;
                  const phone = typeof user.phoneNumber === "string" ? user.phoneNumber : undefined;
                  const result = await checkSignup(
                    options.apiKey ?? process.env.SIGNUP_API_KEY,
                    {
                      email: user.email,
                      phone,
                      ip: ip && !LOOPBACK.has(ip) ? ip : undefined,
                      userAgent: headers?.get("user-agent") ?? undefined,
                    },
                    { baseUrl: options.baseUrl ?? process.env.SIGNUPVET_URL, timeoutMs: options.timeoutMs },
                  );
                  await options.onResult?.(result, user);
                  if (blockOn.includes(result.recommendation)) {
                    throw APIError.from("FORBIDDEN", {
                      code: "SIGNUP_BLOCKED",
                      message: options.blockedMessage ?? "We could not create your account. Please contact support.",
                    });
                  }
                  return { data: { ...user, signupRisk: result.recommendation, signupRiskScore: result.risk_score } };
                },
              },
            },
          },
        },
      };
    },
  } satisfies BetterAuthPlugin;
};

2. Add it to your auth config

import { betterAuth } from "better-auth";
import { signupVet } from "./lib/signupvet";

export const auth = betterAuth({
  // ...your database and other options
  emailAndPassword: { enabled: true },
  plugins: [signupVet()],
});

The plugin reads the key from SIGNUP_API_KEY. You can also pass it as signupVet({ apiKey }).

3. Add the two new columns

The plugin stores the result on the user in signupRisk and signupRiskScore. Create the columns with the Better Auth CLI, as for any plugin:

npx auth@latest generate   # Prisma or Drizzle: then run your own migration
npx auth@latest migrate    # built-in Kysely adapter

4. Use the result in your app

recommendationWhat to do
allowCreate the account as usual.
reviewCreate the account, but hold back free credits or the trial until the email is confirmed, or look at it manually.
blockDo not create the account. Show a neutral message.

On block Better Auth answers 403 with the code SIGNUP_BLOCKED and your neutral message, and no user is created. For review, read session.user.signupRisk on the server and hold back free credits or the trial until the email is confirmed. Users cannot set these fields themselves: Better Auth rejects them in the signup request. To also stop review signups, use signupVet({ blockOn: ["block", "review"] }).

Good to know

Test it

Sign up with test123@mailinator.com: the request fails with 403 and SIGNUP_BLOCKED, and no user appears in your database. A normal address creates the user with signupRisk set. We tested the plugin with Better Auth 1.7.

The file is also on GitHub: github.com/signupvet/examples.

Add it to your signup today

SignupVet checks the email, phone and IP of every new signup in one call and spots waves of signups from the same network. Free for 250 signups a month, no card needed.

Get 250 free checks   Read the docs