How to block fake signups in Next.js
To block fake signups in a Next.js app, check the email, phone and IP of every new signup on the server, right before you create the user. With SignupVet that is one API call in a route handler or server action, and it takes about ten minutes.
Before you start
- Create a free account at signupvet.com/signup (250 checks a month, no card) and confirm your email.
- Create an API key in the dashboard.
- Add it to your server environment, never to browser code:
SIGNUP_API_KEY=sv_live_...
1. Add a small server-only helper
This helper calls the API with a 2 second timeout and fails open: if the API cannot be reached or your monthly checks are used up, it returns allow, so your signup form never breaks. The server-only import makes sure the API key never ends up in browser code.
// 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 the signup before you create the user
In your signup route handler (App Router), call the helper first and only then create the account:
// app/api/signup/route.ts (Next.js App Router)
import { checkSignup, clientIp } from "@/lib/signup-check";
export async function POST(req: Request) {
const { email, password, timezone } = await req.json();
const check = await checkSignup({
email,
ip: clientIp(req.headers),
userAgent: req.headers.get("user-agent") ?? undefined,
timezone,
});
if (check.recommendation === "block") {
return Response.json({ error: "We could not create your account. Please use a different email address." }, { status: 400 });
}
const user = await createUser({ email, password }); // your existing signup
if (check.recommendation === "review") {
await flagForReview(user.id, check.reason_codes); // e.g. no free credits until the email is confirmed
}
return Response.json({ ok: true });
}
// Replace these with your own user store.
async function createUser({ email }: { email: string; password: string }) {
return { id: crypto.randomUUID(), email };
}
async function flagForReview(userId: string, reasons: string[]) {
console.log(`review user ${userId}:`, reasons.join(", "));
}
Using a server action instead? Call checkSignup() the same way and read the visitor's IP with clientIp(await headers()), importing headers from next/headers.
3. Decide what each result means for you
| recommendation | What to do |
|---|---|
allow | Create the account as usual. |
review | Create the account, but hold back free credits or the trial until the email is confirmed, or look at it manually. |
block | Do not create the account. Show a neutral message. |
Every answer also contains reason_codes, for example disposable_domain or signup_velocity_ip, so you can see why a signup was flagged. All codes are listed in the docs.
4. Test it
Sign up with an address like test123@mailinator.com: the result should be block because of a throwaway email provider. Your own normal address should get allow. You can also try addresses and IPs on the live demo first.
Good to know
- Run it on the server. A check in a client component can be skipped by bots.
- Real visitor IP: on Vercel and behind most proxies it is the first entry of
x-forwarded-for, whichclientIp()reads. - Phone numbers work best in international format (+1, +44 …). If you collect a phone number, pass it as
phone. - Using Supabase Auth or Clerk? See the Supabase guide or the Clerk guide.
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.