Clerk sends a verification code to every new email address, which stops some fake signups but not all of them. Disposable inboxes receive the code fine, so trial abusers get through. And every code sent to a typo'd or made-up address bounces, which hurts your sending reputation.
Here are three layers, from zero effort to fully custom. Most apps want layers 1 and 2.
Layer 1: Clerk's built-in restrictions
In the Clerk dashboard, open User & Authentication → Restrictions. Depending on your plan you can:
- Block disposable email domains
- Block email subaddresses (
jane+trial7@gmail.com) - Maintain your own blocklist of domains
This is free to turn on and catches the well-known throwaway domains. It won't catch new disposable domains or addresses that simply don't exist, which is what layer 2 is for.
Layer 2: verify on your server in a custom sign-up flow
If you use Clerk's useSignUp() hook for a custom form, check the address on your server before calling signUp.create(), so no verification code is ever sent to a bad address.
API route (keeps your key on the server):
// app/api/check-email/route.ts
import { NextResponse } from "next/server";
const BLOCK = new Set([
"disposable", "mailbox_not_found", "invalid_syntax", "spamtrap", "mailbox_disabled",
]);
export async function POST(req: Request) {
const { email } = await req.json();
try {
const res = await fetch("https://www.mailrambo.com/v1/verify", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.MAILRAMBO_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ email }),
signal: AbortSignal.timeout(4000),
cache: "no-store",
});
if (!res.ok) return NextResponse.json({ ok: true }); // fail open
const { reason } = await res.json();
return NextResponse.json({ ok: !BLOCK.has(reason), reason });
} catch {
return NextResponse.json({ ok: true }); // fail open
}
}
Sign-up form:
"use client";
import { useSignUp } from "@clerk/nextjs";
const MESSAGES: Record<string, string> = {
disposable: "Please use a permanent email address, not a temporary one.",
mailbox_not_found: "That email address doesn't seem to exist. Check for typos?",
};
export function useScreenedSignUp() {
const { signUp, isLoaded } = useSignUp();
return async (email: string, password: string) => {
const check = await fetch("/api/check-email", {
method: "POST",
body: JSON.stringify({ email }),
}).then((r) => r.json());
if (!check.ok) throw new Error(MESSAGES[check.reason] ?? "Please use a valid email address.");
await signUp!.create({ emailAddress: email, password });
await signUp!.prepareEmailAddressVerification({ strategy: "email_code" });
};
}
A determined abuser could skip your form and call Clerk's frontend API directly, so treat this as the main filter for real users and add layer 3 as the backstop.
Layer 3: a user.created webhook as a safety net
Clerk sends a user.created webhook for every new account, including ones that bypassed your form. Verify there and delete accounts that are clearly fake:
// app/api/webhooks/clerk/route.ts
import { verifyWebhook } from "@clerk/nextjs/webhooks";
import { clerkClient } from "@clerk/nextjs/server";
export async function POST(req: Request) {
const evt = await verifyWebhook(req); // uses CLERK_WEBHOOK_SIGNING_SECRET
if (evt.type !== "user.created") return new Response("ok");
const email = evt.data.email_addresses?.[0]?.email_address;
if (!email) return new Response("ok");
const res = await fetch("https://www.mailrambo.com/v1/verify", {
method: "POST",
headers: { Authorization: `Bearer ${process.env.MAILRAMBO_KEY}`, "Content-Type": "application/json" },
body: JSON.stringify({ email }),
}).catch(() => null);
const verdict = res?.ok ? await res.json() : null;
if (verdict?.reason === "disposable" || verdict?.reason === "spamtrap") {
const clerk = await clerkClient();
await clerk.users.deleteUser(evt.data.id);
}
return new Response("ok");
}
Only auto-delete on reasons that are unambiguous (disposable, spamtrap). For mailbox_not_found, flag the account instead: Clerk's own email verification will stop it from completing anyway.
Test for free
Create a MailRambo test key (mr_test_…) on the API keys page. It never contacts a mail server and uses no credits:
deliverable@example.com→ alloweddisposable@example.com→ blockednot_found@example.com→ blocked
Cost
One check per signup attempt (two if you use layers 2 and 3 together); invalid syntax is free. 100 free checks a month, then from $5 for 1,000.
Tip: add
?mode=fastfor a free check that answers in under a second (syntax, typos likegmial.com, domains with no mail server, disposable providers). Use it inline on the form and keep the full check for when you need a confirmed yes/no. See fast mode.
Check a single address by hand with the free disposable email checker.