Integrations

How to block fake signups with Clerk

Clerk creates new accounts in the browser, so there is no server step before signup where you could run a check. The reliable place is the first server request after signup: check the new user once, store the result in publicMetadata and ban clear fakes.

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_...

Your Clerk keys (CLERK_SECRET_KEY and the publishable key) are already set if Clerk runs in your app.

1. Add the helper

The same small helper as in the Next.js guide: one API call with a 2 second timeout that fails open, so a problem on our side never locks users out.

lib/signup-check.ts

// lib/signup-check.ts: server-only SignupVet check (fails open on errors and timeouts).
import "server-only";

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

const BASE_URL = process.env.SIGNUPVET_URL ?? "https://www.signupvet.com";
const FAIL_OPEN: SignupCheck = { recommendation: "allow", risk_score: null, reason_codes: [], failed_open: true };

export async function checkSignup(signup: {
  email?: string;
  phone?: string;
  ip?: string;
  userAgent?: string;
  timezone?: string;
}): Promise<SignupCheck> {
  try {
    const res = await fetch(`${BASE_URL}/score`, {
      method: "POST",
      headers: {
        Authorization: `Bearer ${process.env.SIGNUP_API_KEY}`,
        "Content-Type": "application/json",
      },
      body: JSON.stringify({
        email: signup.email,
        phone: signup.phone,
        ip: signup.ip,
        context: { user_agent: signup.userAgent, timezone: signup.timezone },
      }),
      signal: AbortSignal.timeout(2000),
      cache: "no-store",
    });
    return res.ok ? ((await res.json()) as SignupCheck) : FAIL_OPEN;
  } catch {
    return FAIL_OPEN;
  }
}

/** Visitor IP on Vercel and most proxies: the first entry of x-forwarded-for. */
export function clientIp(headers: Headers): string | undefined {
  return headers.get("x-forwarded-for")?.split(",")[0]?.trim() || headers.get("x-real-ip") || undefined;
}

2. Check new users once

This function runs on the server. For a user who signed up in the last 24 hours and has not been checked yet, it sends the email, phone and IP to SignupVet, stores the result and bans the account if the result is block. Every later call just reads the stored result, so it costs one check per user.

lib/signup-gate.ts

// lib/signup-gate.ts (Next.js App Router + Clerk)
//
// Clerk creates the account in the browser, so there is no server step before signup to hook into.
// Instead, run the check once on the server the first time a new user opens your app, and keep the
// result in publicMetadata: only your backend can change it, users cannot.
import "server-only";
import { headers } from "next/headers";
import { clerkClient, currentUser } from "@clerk/nextjs/server";
import { checkSignup, clientIp } from "@/lib/signup-check"; // same file as in ../nextjs/lib

export type SignupRisk = "allow" | "review" | "block";

const NEW_USER_WINDOW_MS = 24 * 60 * 60 * 1000;

export async function signupRisk(): Promise<SignupRisk | null> {
  const user = await currentUser();
  if (!user) return null;

  const stored = user.publicMetadata.signup_risk as SignupRisk | undefined;
  if (stored) return stored;
  // Only check fresh signups, never users who signed up before you added this.
  if (Date.now() - user.createdAt > NEW_USER_WINDOW_MS) return "allow";

  const h = await headers();
  const check = await checkSignup({
    email: user.primaryEmailAddress?.emailAddress,
    phone: user.primaryPhoneNumber?.phoneNumber,
    ip: clientIp(h),
    userAgent: h.get("user-agent") ?? undefined,
  });
  // The API could not be reached: let the user in and try again on the next visit.
  if (check.failed_open) return "allow";

  const client = await clerkClient();
  await client.users.updateUserMetadata(user.id, {
    publicMetadata: { signup_risk: check.recommendation, signup_reasons: check.reason_codes },
  });
  // A ban signs the user out and can be undone in the Clerk dashboard.
  if (check.recommendation === "block") await client.users.banUser(user.id);
  return check.recommendation;
}

publicMetadata can only be changed from your backend, so a bot cannot remove its own flag. You can also read it in the browser as user.publicMetadata.signup_risk. A ban signs the user out, blocks new sign-ins and can be undone in the Clerk dashboard if you ever need to.

3. Call it where your app starts

Call signupRisk() in the layout of the signed-in part of your app, so it runs on the first page a new user sees:

app/(app)/layout.tsx

// app/(app)/layout.tsx: the signed-in part of your app (protected by clerkMiddleware).
import { redirect } from "next/navigation";
import { signupRisk } from "@/lib/signup-gate";

export default async function AppLayout({ children }: { children: React.ReactNode }) {
  const risk = await signupRisk();
  if (risk === "block") redirect("/blocked"); // a public page with a neutral message
  // For "review": hold back free credits or the trial until you have looked at the account.
  return children;
}

Make /blocked a public page with a short, neutral message.

4. Decide what each result means for you

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.

The most useful place for review is wherever you hand out free credits or start a trial: give those users less until you have looked at them.

Good to know

Test it

Sign up with test123@mailinator.com in your development instance: after the first page load the user is banned and sent to /blocked. A normal address gets signup_risk: "allow" in the user's public metadata in the Clerk dashboard.

The full examples are 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