1. Home
  2. Signup protection
  3. Clerk

Signup protection recipe

Block fake and disposable signups in Clerk

Clerk sends a user.created webhook right after a sign-up. Verify it with verifyWebhook from @clerk/nextjs/webhooks, check the primary address with SimpleVerifier, delete the user for disposable, invalid and misspelled addresses, and write a flag to private metadata for role and catch-all ones. To stop the account before it exists, run the same check in a custom sign-up form.

Where it runs
The webhook runs just after Clerk creates the user, so a blocked account exists for a moment and is then deleted. A custom sign-up form (step 4) checks before the account is created.
Timeout
Webhooks are retried if your endpoint fails, so the snippet allows 8 seconds and always answers 200 once the signature checks out.

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. 1. Check Clerk's own restrictions first

    Clerk's dashboard has Restrictions settings (allowlist, blocklist and, depending on plan, blocking of disposable domains and subaddresses). Turn on what you have. The steps below add the checks a static list can't make: whether the domain takes mail, typos, and invented-looking addresses.

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

    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 };
    }
  3. 3. Add the webhook route

    Create the endpoint in Clerk under Configure > Webhooks, subscribe it to user.created, and copy the signing secret into CLERK_WEBHOOK_SIGNING_SECRET. Keep /api/webhooks public in your middleware.

    app/api/webhooks/clerk/route.ts
    import type { NextRequest } from "next/server";
    import { verifyWebhook } from "@clerk/nextjs/webhooks";
    import { clerkClient } from "@clerk/nextjs/server";
    import { checkSignupEmail } from "@/lib/signup-guard";
    
    export async function POST(req: NextRequest) {
      let evt;
      try {
        evt = await verifyWebhook(req); // reads CLERK_WEBHOOK_SIGNING_SECRET
      } catch {
        return new Response("Error verifying webhook", { status: 400 });
      }
      if (evt.type !== "user.created") return new Response("ignored", { status: 200 });
    
      const user = evt.data;
      const email = user.email_addresses.find((e) => e.id === user.primary_email_address_id)?.email_address;
      if (!email) return new Response("no email", { status: 200 });
    
      const verdict = await checkSignupEmail(email, { dryRun: process.env.SIGNUP_GUARD_DRY_RUN === "1" });
      const client = await clerkClient();
      if (verdict.decision === "block") {
        await client.users.deleteUser(user.id);
      } else if (verdict.decision === "warn") {
        await client.users.updateUserMetadata(user.id, {
          privateMetadata: { emailCheck: "warn", emailReason: verdict.reasonCode },
        });
      }
      return new Response("ok", { status: 200 });
    }
  4. 4. Or check before the account exists

    With a custom sign-up flow (useSignUp), call a server action with the address before signUp.create and show the message on the form. The webhook then catches anything that skipped the form.

    app/sign-up/check-email.ts
    "use server";
    
    import { checkSignupEmail } from "@/lib/signup-guard";
    
    export async function checkEmailBeforeSignUp(email: string): Promise<string | null> {
      const verdict = await checkSignupEmail(email);
      return verdict.decision === "block" ? verdict.message : null; // show this under the field
    }

More on calling the API from this stack (retries, status handling, tests): API guide. Every field is described in the API docs.

Common questions

Why delete the user instead of banning them?
A disposable or misspelled address usually belongs to nobody, so there is nothing to keep. Banning keeps the row and blocks the address from signing up again; use client.users.banUser(user.id) instead if you want that.
What does the person see when the webhook deletes them?
Their session ends on the next request. That's why the custom sign-up check is worth adding: it shows a clear message on the form instead.
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

Clean your next list before it bounces

100 free email checks when you sign in with Google, no card. Then monthly plans from $9, or one flat price with fair use, never cut off.

100 free checks, no card Cancel anytime Secure payment by Stripe