Supabase makes signup easy, and that includes making it easy for people to sign up with @mailinator.com addresses, typos and made-up mailboxes. Those accounts eat your free tier, skew your metrics and make your confirmation emails bounce, and bounces hurt the reputation of every email you send.
The clean fix is to reject bad addresses before the user row is created. Supabase supports this with the Before User Created auth hook: Supabase calls your function with the new user's email, and your function either allows the signup or returns an error that the client shows to the user.
What to block (and what not to)
Verify the address with an API that gives you a reason, then block only what is clearly bad:
reason |
Block at signup? | Why |
|---|---|---|
disposable |
✅ Yes | Throwaway inbox, classic trial abuse |
mailbox_not_found |
✅ Yes | Typo or made-up address; confirmation email would hard-bounce |
invalid_syntax |
✅ Yes | Not an email address |
spamtrap, mailbox_disabled |
✅ Yes | Sending here damages your reputation |
catch_all, unverifiable, inbox_full |
❌ Allow | Often real people at companies with strict mail servers |
mailbox_exists, role_account |
❌ Allow | Real mailboxes |
And fail open: if the verification call errors or times out, let the signup through. A verification outage should never become a signup outage.
1. Create the Edge Function
supabase functions new before-user-created
// supabase/functions/before-user-created/index.ts
import { Webhook } from "https://esm.sh/standardwebhooks@1.0.0";
const BLOCK = new Set([
"disposable", "mailbox_not_found", "invalid_syntax", "spamtrap", "mailbox_disabled",
]);
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?",
};
async function verify(email: string): Promise<{ deliverable: boolean; reason: string } | null> {
try {
const res = await fetch("https://www.mailrambo.com/v1/verify", {
method: "POST",
headers: {
Authorization: `Bearer ${Deno.env.get("MAILRAMBO_KEY")}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ email }),
signal: AbortSignal.timeout(4000),
});
return res.ok ? await res.json() : null;
} catch {
return null; // fail open
}
}
Deno.serve(async (req) => {
const payload = await req.text();
const secret = Deno.env.get("BEFORE_USER_CREATED_HOOK_SECRET")!.replace("v1,whsec_", "");
let event: { user: { email?: string } };
try {
event = new Webhook(secret).verify(payload, Object.fromEntries(req.headers)) as typeof event;
} catch {
return new Response("invalid signature", { status: 401 });
}
const email = event.user.email;
if (!email) return Response.json({}); // phone / OAuth without email: allow
const verdict = await verify(email);
if (verdict && BLOCK.has(verdict.reason)) {
return Response.json({
error: {
http_code: 400,
message: MESSAGES[verdict.reason] ?? "Please sign up with a valid email address.",
},
}, { status: 400 });
}
return Response.json({}); // allow
});
Returning {} allows the signup. Returning an error object with a 4xx status rejects it, and the message is what supabase.auth.signUp() gives back to your client.
2. Deploy it and set the secrets
supabase functions deploy before-user-created --no-verify-jwt
supabase secrets set MAILRAMBO_KEY=mr_live_...
--no-verify-jwt is needed because Supabase Auth calls the hook with a webhook signature rather than a user JWT; the function checks that signature itself.
3. Turn on the hook
In the Supabase dashboard go to Authentication → Hooks → Before User Created, choose HTTPS, and paste your function URL (https://<project-ref>.supabase.co/functions/v1/before-user-created). Supabase generates a secret that starts with v1,whsec_. Store it:
supabase secrets set BEFORE_USER_CREATED_HOOK_SECRET='v1,whsec_...'
4. Show the error nicely
Nothing changes in your signup code; just surface the message:
const { error } = await supabase.auth.signUp({ email, password });
if (error) setFormError(error.message); // "Please use a permanent email address…"
Test without spending credits
Create a test key (mr_test_…) on the API keys page and use it as MAILRAMBO_KEY in a staging project. Test keys never call a mail server and never use credits; the answer depends on the local part:
deliverable@example.com→ alloweddisposable@example.com→ blocked with the "permanent email" messagenot_found@example.com→ blocked with the typo message
Switch to your mr_live_ key in production.
Cost
One verification per signup attempt. Invalid-syntax addresses are free. The Free plan's 100 checks a month covers a side project; after that, the $5 Starter plan covers 1,000 signups a month.
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.
Want to check an address by hand first? Try the free disposable email checker or the email verifier.