Signup protection recipe
Block fake and disposable signups in Next.js
In a Next.js App Router app, run the check in the server action (or route handler) that creates the account, before you call your auth library or insert the user. Block disposable, invalid and misspelled addresses with a message on the form, save role and catch-all flags with the account, and let every unfinished check through.
- Where it runs
- Runs in your own server code before the account is created, so you choose exactly what happens.
- Timeout
- No platform limit here, but people are waiting on the form: the snippet uses 8 seconds and then lets the signup through.
You need an API key from the API keys page (Pro, Business or Agency), stored server-side as SIMPLEVERIFIER_KEY. Reviewed 2026-10-31.
Block, warn or allow
- Block: disposable or temporary inbox (reason_code "disposable" (action "do_not_send"))
- Block: invalid: bad format, no mail server, mailbox not found (action "do_not_send" (reason_code "syntax", "no_mx", "mailbox_not_found", "domain_not_hosted"))
- Block: misspelled domain (gmial.com, hotmial.com) (reason_code "typo_domain" (action "do_not_send"))
- Warn: role inbox (info@, sales@, admin@) (reason_code "role")
- Warn: catch-all domain (reason_code "catch_all")
- Warn: looks invented (test@, john.doe@, example.com) (likely_invented true)
- Allow: valid, or the check could not finish (action "send", "send_with_care", "retry_later", or any non-2xx answer or timeout)
The reasoning for each row is on the signup protection overview.
1. Add the guard helper
One file, no dependencies, never throws. It blocks only what the table above says to block and lets every unfinished check through.
lib/signup-guard.ts // signup-guard.ts: server code only. Needs fetch and AbortSignal.timeout (Node 18+, Deno, Workers, Bun). export type Decision = "block" | "warn" | "allow"; export interface Verdict { decision: Decision; /** Stable SimpleVerifier reason_code, or null when the check didn't finish. */ reasonCode: string | null; /** Message to show the person when decision is "block". */ message: string | null; } interface Options { apiKey?: string; /** Keep it under your platform's hook limit. A timeout lets the signup through. */ timeoutMs?: number; /** Log what would be blocked, block nothing. Run this way for a few days first. */ dryRun?: boolean; } const API_URL = "https://simpleverifier.com/api/v1/verify"; const WARN_CODES = new Set(["role", "catch_all"]); const ALLOW: Verdict = { decision: "allow", reasonCode: null, message: null }; function blockMessage(reasonCode: string, reason: string): string { if (reasonCode === "disposable") return "Please use a permanent email address, not a temporary inbox."; const hint = reason.match(/did you mean ([^?\s]+)\??/i)?.[1]; if (hint) return `Check the spelling of your email address. Did you mean ${hint}?`; return "This email address can't receive mail. Please check it."; } export async function checkSignupEmail(email: string, options: Options = {}): Promise<Verdict> { const { apiKey = process.env.SIMPLEVERIFIER_KEY, timeoutMs = 8000, dryRun = false } = options; if (!apiKey) return ALLOW; let result: { action?: string; reason?: string; reason_code?: string; likely_invented?: boolean }; try { const res = await fetch(API_URL, { method: "POST", headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json" }, body: JSON.stringify({ email: email.trim() }), signal: AbortSignal.timeout(timeoutMs), }); if (!res.ok) return ALLOW; // 401, 402, 429, 503: fail open result = await res.json(); } catch { return ALLOW; // timeout or network error: fail open } const reasonCode = result.reason_code ?? null; if (result.action === "do_not_send") { const verdict: Verdict = { decision: "block", reasonCode, message: blockMessage(reasonCode ?? "", result.reason ?? "") }; if (!dryRun) return verdict; console.info("[signup-guard] dry run, would block", { reasonCode }); return { ...verdict, decision: "warn" }; } if (result.likely_invented || (reasonCode && WARN_CODES.has(reasonCode))) { return { decision: "warn", reasonCode, message: null }; } return { decision: "allow", reasonCode, message: null }; }2. Check the address in the sign-up action
Works with any auth library (Auth.js, Better Auth, Lucia, your own). Put the check before the call that creates the user.
app/signup/actions.ts "use server"; import { checkSignupEmail } from "@/lib/signup-guard"; export interface SignupState { error?: string; ok?: boolean; } export async function signup(_prev: SignupState, formData: FormData): Promise<SignupState> { const email = String(formData.get("email") ?? "").trim(); const verdict = await checkSignupEmail(email, { dryRun: process.env.SIGNUP_GUARD_DRY_RUN === "1" }); if (verdict.decision === "block") return { error: verdict.message ?? "Please use a different email address." }; // Create the user here with your auth library, and store the flag with it: // await createUser({ email, emailCheck: verdict.decision, emailReason: verdict.reasonCode }); return { ok: true }; }3. Or guard an API route
For a JSON sign-up endpoint called from a client component or a mobile app.
app/api/signup/route.ts import { NextResponse } from "next/server"; import { checkSignupEmail } from "@/lib/signup-guard"; export async function POST(request: Request) { const body = (await request.json().catch(() => ({}))) as { email?: unknown }; const email = typeof body.email === "string" ? body.email.trim() : ""; if (!email) return NextResponse.json({ error: "Email is required." }, { status: 400 }); const verdict = await checkSignupEmail(email); if (verdict.decision === "block") return NextResponse.json({ error: verdict.message }, { status: 422 }); // create the user, then: return NextResponse.json({ ok: true, emailCheck: verdict.decision }); }
More on calling the API from this stack (retries, status handling, tests): API guide. Every field is described in the API docs.
Common questions
- Should this go in middleware instead?
- No. Middleware runs on every request and can't easily read a form body. Put the check where the account is created; that's one call per real sign-up attempt.
- Can the key be NEXT_PUBLIC_?
- Never. A NEXT_PUBLIC_ variable is shipped to every visitor's browser, and anyone could use your plan with it. Keep SIMPLEVERIFIER_KEY server-only.
- Which plan includes the API?
- Pro ($29.99/month, 10,000 calls a month included), Business and Agency. Each address checked is one call. Starter and Growth have no API access.
- How do I try it without blocking anyone?
- Set SIGNUP_GUARD_DRY_RUN=1. The code logs what it would block and lets everyone through. Read the logs for a few days, then remove the variable.
Other recipes
- Block fake and disposable signups in Supabase Auth
- Block fake and disposable signups in Clerk
- Block fake and disposable signups in Firebase Auth
- Express middleware that blocks fake signups
- Block fake signups at the edge with a Cloudflare Worker
- Filter fake form signups in Zapier or n8n
- Filter fake Webflow and Framer form signups