Block Fake and Disposable Signups in Supabase Auth

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 → allowed
  • disposable@example.com → blocked with the "permanent email" message
  • not_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=fast for a free check that answers in under a second (syntax, typos like gmial.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.

Run these checks from your code

Verify addresses at signup, clean lists and read SPF/DKIM/DMARC with one API call. 100 free verifications a month, test keys that cost nothing, and the API on every plan.