Block disposable emails in Better Auth and Clerk
Call detect in a Better Auth before hook or before Clerk signUp. Fail open on timeouts. Do not rely on a static list or a post-create webhook.
Block disposable emails in Better Auth and Clerk by classifying the address on your server before the user row exists. Call GET /api/v1/emails/detect, reject when disposable is true, and fail open on timeout or 5xx. Clerk’s built-in disposable toggle and the community MailChecker plugin are useful first filters. They are not the same as EmailGuard flags, and a user.created webhook is too late to be the only gate.
This walkthrough assumes you already have a key with email:detect. See Creating API keys and your first detect check. Policy background: block disposable emails at signup and fail open vs fail closed.
hooks.before on POST /sign-up/email.signUp.password (or signUp.create on email-link).user.created + banUser in their actual roles (static list vs cleanup).Prerequisites: Auth server you control (Better Auth auth.ts or a Next.js route). Never put the EmailGuard key in NEXT_PUBLIC_*. Same rule Clerk documents for CLERK_SECRET_KEY.
| Signal | MailChecker plugin | Clerk disposable toggle | EmailGuard |
|---|---|---|---|
| Throwaway inbox | !MailChecker.isValid → 400 | Instance list blocks signup | disposable === true → block |
| Privacy relay | Often missing or mixed | Not a separate control | relay_domain → your relay policy |
| Gmail / Outlook.com | Valid | Not disposable | public_domain → work-email policy, not spam |
info@ / admin@ | Valid | Not covered | role_address → role policy |
| Checker down | N/A (in-process list) | Clerk’s availability | Timeout / 5xx → allow + log |
better-auth-no-disposable-emails (npm 0.2.0, published 2026-03-31) wraps mailchecker. The list updates when you bump the package. New throwaway MX miss until then. If you await detect() in that hook and throw on network errors, every EmailGuard blip fails closed. Do not do that.
Clerk Dashboard → Rules → “Block sign-ups that use disposable email addresses” (blockDisposableEmailDomains on updateRestrictions). Clerk does not document list size, cadence, or relay vs disposable. Use it. Do not pretend it returns role_address.
Official hook docs (fetched 2026-09-03): one hooks.before middleware, branch on ctx.path, throw APIError from better-auth/api. Endpoint for email/password signup is POST /sign-up/email.
import { betterAuth } from "better-auth";
import { APIError, createAuthMiddleware } from "better-auth/api";
export const auth = betterAuth({
emailAndPassword: { enabled: true },
hooks: {
before: createAuthMiddleware(async (ctx) => {
if (ctx.path !== "/sign-up/email") return;
const email = ctx.body?.email;
if (typeof email !== "string") return;
const result = await detectEmail(email);
if (result === "skipped") return; // fail open
if (result.disposable) {
throw new APIError("BAD_REQUEST", {
message: "Use a permanent email address. Temporary inboxes are not allowed.",
});
}
}),
},
});
async function detectEmail(email: string) {
try {
const res = await fetch(
`${process.env.EMAILGUARD_API_BASE}/api/v1/emails/detect?email=${encodeURIComponent(email)}`,
{
headers: { Authorization: `Bearer ${process.env.EMAILGUARD_API_KEY}` },
signal: AbortSignal.timeout(800),
},
);
if (!res.ok) return "skipped";
const body = await res.json();
return body.data;
} catch {
return "skipped";
}
}Cover OAuth/social user create with databaseHooks.user.create.before if those paths can mint accounts. user.emailValidator was still an open PR on Better Auth (#11002, 2026-09-02). Do not document it as shipped.
The MailChecker plugin is the no-network alternative:
import { noDisposableEmails } from "better-auth-no-disposable-emails";
plugins: [
noDisposableEmails({
errorMessage: "Use a permanent email address.",
paths: ["/sign-up/email"],
}),
],Default path is signup only. Magic link and OTP need extra paths. Prefer the detect hook when you want relay and role flags in the same response. Use official SDKs instead of raw fetch if you already depend on a client. Log disposable, relay_domain, detection_source, and whether you skipped. Do not log the full email in the same line as the API key prefix.
Timeouts should match classification, not SMTP. Start at 400–800 ms so live DNS can finish. See fail open vs fail closed for why a 200 ms abort misses new disposable domains.
Two layers:
1. Dashboard (prebuilt <SignUp />). Turn on disposable blocking. This runs inside Clerk. You cannot fail-open it, and you cannot read relay_domain.
2. Classify before Clerk creates the user. Custom email/password flow (Clerk docs, 2026): useSignUp() then signUp.password({ emailAddress, password }). Email-link flows still use signUp.create({ emailAddress }). Either way, hit your Route Handler first.
// app/api/email-guard/route.ts
import { NextResponse } from "next/server";
export async function POST(req: Request) {
const { email } = await req.json();
if (typeof email !== "string") {
return NextResponse.json({ ok: false, reason: "invalid" }, { status: 400 });
}
try {
const res = await fetch(
`${process.env.EMAILGUARD_API_BASE}/api/v1/emails/detect?email=${encodeURIComponent(email)}`,
{
headers: { Authorization: `Bearer ${process.env.EMAILGUARD_API_KEY}` },
signal: AbortSignal.timeout(800),
},
);
if (!res.ok) return NextResponse.json({ ok: true, skipped: true });
const body = await res.json();
if (body.data?.disposable) {
return NextResponse.json(
{ ok: false, reason: "disposable" },
{ status: 400 },
);
}
return NextResponse.json({ ok: true });
} catch {
return NextResponse.json({ ok: true, skipped: true });
}
}On the client, await fetch("/api/email-guard", …) and only then signUp.password. Keys stay on the server. Clerk’s publishable key is public. Yours is not.
Webhook is cleanup, not onboarding. Clerk’s webhook overview says delivery is not guaranteed immediately or at all, and you must not treat it as part of signup. user.created can fire after the User exists, a session exists, and a verification email went out. verifyWebhook from @clerk/nextjs/webhooks, then optionally clerkClient.users.banUser(userId) if detect says disposable. That is a safety net for prebuilt UI you cannot intercept. It is a race. Prefer the custom flow for a hard block.
Honor rate limits (Retry-After on 429). Cache 2xx if the same address is submitted twice. See how to cache email validation API results.
Signup is not the only path. Better Auth user.update and Clerk “add email” can attach a throwaway later. Run the same detect helper on email-change endpoints. Clerk’s disposable toggle claims to block disposable addresses added to existing accounts as well as sign-ups. Confirm that in your Dashboard. Still classify on your server if you need relay_domain on the new address.
For Better Auth, a second ctx.path branch is cheaper than hoping the plugin’s paths array is complete:
const EMAIL_PATHS = new Set([
"/sign-up/email",
"/change-email",
]);
if (!EMAIL_PATHS.has(ctx.path)) return;Exact change-email path names depend on which plugins you enabled. Read ctx.path in a staging log once rather than copying a guessed string.
Database hooks run before the user row is written. Official TOS examples throw APIError or return false to abort. Use this when OAuth can create users without hitting /sign-up/email:
databaseHooks: {
user: {
create: {
before: async (user) => {
const result = await detectEmail(user.email);
if (result === "skipped") return { data: user };
if (result.disposable) {
throw new APIError("BAD_REQUEST", {
message: "Use a permanent email address.",
});
}
return { data: user };
},
},
},
},Endpoint hooks reject earlier (less auth work). Database hooks catch social sign-up. You can run both. Do not SMTP-probe in either.
If you must keep prebuilt <SignUp />, classify after the fact and ban. Clerk’s webhook overview (fetched 2026-09-03) says delivery is asynchronous and not part of onboarding. Between user.created and your handler the account is real.
import { verifyWebhook } from "@clerk/nextjs/webhooks";
import { clerkClient } from "@clerk/nextjs/server";
export async function POST(req: Request) {
const evt = await verifyWebhook(req);
if (evt.type !== "user.created") return new Response("ok");
const email =
evt.data.email_addresses.find(
(row) => row.id === evt.data.primary_email_address_id,
)?.email_address ?? evt.data.email_addresses[0]?.email_address;
if (!email) return new Response("ok");
const result = await detectEmail(email);
if (result !== "skipped" && result.disposable) {
const client = await clerkClient();
await client.users.banUser(evt.data.id);
}
return new Response("ok");
}Verify the Svix signature before you call detect or banUser. A forged event could ban people. This path still sends a verification email if Clerk already queued one. Prefer the custom flow when a hard block matters.
mailinator.com or another known throwaway: Better Auth returns your 400; Clerk custom flow never calls signUp.password.gmail.com: allowed unless you also apply a work-email rule on public_domain.AbortSignal 1 ms): signup still succeeds. Log skipped.user.created for a throwaway and confirm banUser. Then confirm a legitimate user is not banned when detect is skipped.Treat 429 like a skip on the signup thread. Read Retry-After, do not retry inside the hook, and do not tell the user their email is invalid. That is an ops signal, same as fail open.
No first-party disposable plugin. Use callbacks.signIn on the server. For the Email provider, the first callback has email.verificationRequest === true. That is the moment to skip sending a magic link to a throwaway. Return false or a URL. Call the same detectEmail helper. Suffix allowlists in the Auth.js restricting-access guide are not a disposable policy.
callbacks: {
async signIn({ email, user }) {
const address = email?.identifier ?? user?.email;
if (!address) return true;
if (email?.verificationRequest) {
const result = await detectEmail(address);
if (result !== "skipped" && result.disposable) return false;
}
return true;
},
},Auth.js field names differ slightly by version (user.email vs profile.email). Log the callback payload in development once. The rule stays the same: server-side detect, fail open, no SMTP.
| What you have | Do this |
|---|---|
| Better Auth email/password | hooks.before on /sign-up/email |
| Better Auth + Google/GitHub | Add databaseHooks.user.create.before |
Clerk prebuilt <SignUp /> only | Dashboard disposable toggle + webhook ban as cleanup |
| Clerk, you can customize the form | Route Handler then signUp.password |
| Auth.js Email provider | signIn when verificationRequest is true |
If you only turn on Clerk’s Dashboard toggle, you still have no relay_domain policy and no fail-open of your detect call, because Clerk never called you. That is fine for a first week. It is not the same as the table in this doc.
Throwing on timeout. That is fail-closed signup. Log email_check=skipped and allow.
Calling detect from authClient or a Clerk client component with the secret. Proxy it.
Blocking relay_domain because MailChecker said nothing and Clerk said nothing. Relays are not throwaways. See the relay guide.
Using user.created as the only Clerk gate. The account already exists.
SMTP-probing from the hook. EmailGuard classifies. Existence proof is a confirmation email, always.
Failing closed on 401 from detect. Users see “invalid email.” You have a bad key. Page ops, not the signup form.
Yes. Dashboard Rules can block known disposable domains, and the Backend API exposes blockDisposableEmailDomains. That list is Clerk’s. It does not return relay_domain or role_address.
Use hooks.before with createAuthMiddleware, branch on ctx.path === "/sign-up/email", read ctx.body.email, call detect, throw APIError only when disposable is true.
For a static domain list, yes. It will not classify Apple Hide My Email, and it will not fail open because it never calls the network. Pair it with detect if you need those flags.
No. Proxy through a Route Handler. NEXT_PUBLIC_ is for Clerk’s publishable key, not yours.
Use callbacks.signIn on the server. For the Email provider, the first invocation has email.verificationRequest === true. Call detect there before you send the magic link.
No. disposable is the hard block. relay_domain and public_domain are separate product decisions. Mixing them is how you reject Apple Hide My Email and every Gmail founder.