Block fake signups in Clerk and Supabase Auth
Clerk and Supabase ship a static disposable-email list - it misses fresh throwaway domains. Add a Mailsurity check via webhook or edge function to catch the rest.
What Clerk and Supabase give you out of the box
Both Clerk and Supabase can block disposable emails, but off a static list. That catches the well-known providers and misses the long tail: freshly registered throwaway domains, and the thousands of rotating domains that all point at the same handful of throwaway mail servers. Those sail straight through a built-in list.
Clerk: check on the user.created webhook
Add a webhook on Clerk's user.created event. Always verify the signature with svix first (reject forged calls), then run the email through the Mailsurity SDK and ban the user if it's disposable. This layers on top of Clerk's own filtering rather than replacing it. You'll need npm i mailsurity svix @clerk/nextjs and your CLERK_WEBHOOK_SECRET.
import { Webhook } from 'svix';
import { headers } from 'next/headers';
import { clerkClient } from '@clerk/nextjs/server';
import { Mailsurity } from 'mailsurity';
const ms = new Mailsurity({ apiKey: process.env.MAILSURITY_API_KEY! });
export async function POST(req: Request) {
// 1. Verify the webhook signature and reject forged calls.
const payload = await req.text();
const h = await headers();
let evt: any;
try {
evt = new Webhook(process.env.CLERK_WEBHOOK_SECRET!).verify(payload, {
'svix-id': h.get('svix-id')!,
'svix-timestamp': h.get('svix-timestamp')!,
'svix-signature': h.get('svix-signature')!,
});
} catch {
return new Response('Invalid signature', { status: 400 });
}
if (evt.type !== 'user.created') return Response.json({ ok: true });
// 2. Check the email; ban the user if it's disposable.
const email = evt.data.email_addresses?.[0]?.email_address;
if (email && (await ms.isDisposable(email))) {
await (await clerkClient()).users.banUser({ userId: evt.data.id });
}
return Response.json({ ok: true });
}Clerk: block before the account is created (custom sign-up UI)
The webhook approach above creates the user, then bans them. That's fine for Clerk's hosted <SignUp/> component, which doesn't let you intercept the form before submission. If you build your own sign-up form with useSignUp(), you can check the email before calling signUp.create() and skip creating (and cleaning up) the account at all.
import { mailsurity } from '@/lib/mailsurity';
export async function POST(req: Request) {
const { email } = await req.json();
if (await mailsurity.isDisposable(email)) {
return Response.json(
{ error: 'Please use a permanent email address.' },
{ status: 422 },
);
}
return Response.json({ ok: true });
}const { isLoaded, signUp } = useSignUp();
async function handleSubmit(email: string, password: string) {
if (!isLoaded) return;
// Check before calling Clerk, to block the signup pre-creation.
const res = await fetch('/api/check-signup', {
method: 'POST',
body: JSON.stringify({ email }),
});
const { error } = await res.json();
if (error) return setFormError(error);
await signUp.create({ emailAddress: email, password });
// ...continue Clerk's verification flow
}Supabase: check in an Edge Function
With Supabase, call the check from your signup Edge Function before the insert (or from a trigger on auth.users), and reject the request for disposable addresses.
import { Mailsurity } from 'npm:mailsurity';
const ms = new Mailsurity({ apiKey: Deno.env.get('MAILSURITY_API_KEY')! });
Deno.serve(async (req) => {
const { email } = await req.json();
if (await ms.isDisposable(email)) {
return new Response(
JSON.stringify({ error: 'Disposable email not allowed' }),
{ status: 422, headers: { 'Content-Type': 'application/json' } },
);
}
return new Response(JSON.stringify({ ok: true }), {
headers: { 'Content-Type': 'application/json' },
});
});The point: catch what the built-in list misses
Mailsurity fingerprints the mail servers behind throwaway addresses, so a brand-new disposable domain is flagged the moment it points at a known throwaway host, without waiting for a list to be updated. Run the audit on your real signups to see exactly how many are getting past your current auth setup.
A more precise alternative: branch on a decision, not just a boolean
The webhook above only has two outcomes: ban or don't. Mailsurity's decision API, POST /api/assess, returns a risk score plus a resolved ALLOW / VERIFY / BLOCK instead of a plain boolean, so a signup that's genuinely on the fence doesn't have to land in the same bucket as an obvious disposable one. It gives you a third option between “let them in” and “ban outright”: leave ALLOW users alone, ban on BLOCK, and flag VERIFY users for extra scrutiny instead of forcing a binary call.
import { Webhook } from 'svix';
import { headers } from 'next/headers';
import { clerkClient } from '@clerk/nextjs/server';
// The mailsurity SDK doesn't wrap /api/assess yet, so this calls it
// directly. Same webhook-signature verification as the boolean example.
export async function POST(req: Request) {
const payload = await req.text();
const h = await headers();
let evt: any;
try {
evt = new Webhook(process.env.CLERK_WEBHOOK_SECRET!).verify(payload, {
'svix-id': h.get('svix-id')!,
'svix-timestamp': h.get('svix-timestamp')!,
'svix-signature': h.get('svix-signature')!,
});
} catch {
return new Response('Invalid signature', { status: 400 });
}
if (evt.type !== 'user.created') return Response.json({ ok: true });
const email = evt.data.email_addresses?.[0]?.email_address;
if (!email) return Response.json({ ok: true });
let decision = 'ALLOW'; // fail open: an outage should never ban a real user
try {
const res = await fetch('https://mailsurity.com/api/assess', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.MAILSURITY_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ email }),
});
if (res.ok) ({ decision } = await res.json());
} catch {
// network error or timeout — decision stays 'ALLOW'
}
const client = await clerkClient();
if (decision === 'BLOCK') {
await client.users.banUser({ userId: evt.data.id });
} else if (decision === 'VERIFY') {
// Ambiguous case: leave the account active, but flag it instead of
// banning outright — surface it in your own review queue or gate
// further access behind email confirmation / step-up MFA.
await client.users.updateUserMetadata(evt.data.id, {
publicMetadata: { signupRisk: 'verify' },
});
}
return Response.json({ ok: true });
}The same three-way branch works in the pre-create or Supabase Edge Function variants above. Call /api/assess instead of isDisposable() and switch on decision. It doesn't replace the boolean endpoint, since both call the same underlying detection; pick whichever shape fits how precisely you want to react to a borderline signup.
See how many disposable signups slip past your current setup
Paste a list of your signups. Mailsurity shows how many a standard blocklist misses that we catch by mail-server fingerprint. No signup needed; the part before the @ never leaves your browser.
Need the full field and error reference? See the API reference.