1. Home
  2. Signup protection
  3. Supabase

Signup protection recipe

Block fake and disposable signups in Supabase Auth

Supabase Auth calls a Before User Created hook before it writes the user row. Point it at an Edge Function that verifies the Standard Webhooks signature, checks the address with SimpleVerifier, and returns an error for disposable, invalid and misspelled addresses. The person sees your message, and no account is created.

Where it runs
Runs before the user exists. A blocked address never becomes an auth.users row.
Timeout
Supabase gives HTTP hooks 5 seconds, so the snippet waits 4 seconds and then lets the signup through.

You need an API key from the API keys page (Pro, Business or Agency), stored server-side as SIMPLEVERIFIER_KEY. Reviewed 2026-10-31.

Block, warn or allow

  • Block: disposable or temporary inbox (reason_code "disposable" (action "do_not_send"))
  • Block: invalid: bad format, no mail server, mailbox not found (action "do_not_send" (reason_code "syntax", "no_mx", "mailbox_not_found", "domain_not_hosted"))
  • Block: misspelled domain (gmial.com, hotmial.com) (reason_code "typo_domain" (action "do_not_send"))
  • Warn: role inbox (info@, sales@, admin@) (reason_code "role")
  • Warn: catch-all domain (reason_code "catch_all")
  • Warn: looks invented (test@, john.doe@, example.com) (likely_invented true)
  • Allow: valid, or the check could not finish (action "send", "send_with_care", "retry_later", or any non-2xx answer or timeout)

The reasoning for each row is on the signup protection overview.

  1. 1. Create the function and its secrets

    Generate the hook secret under Authentication > Hooks > Before User Created (HTTP) in the dashboard. It looks like v1,whsec_....

    supabase functions new before-user-created
    supabase secrets set SIMPLEVERIFIER_KEY=sv_live_... BEFORE_USER_CREATED_HOOK_SECRET='v1,whsec_...'
  2. 2. Write the hook

    Deno runs it, so the key comes from Deno.env. Rejections are a 400 with an error object; Supabase passes the message to the client that tried to sign up.

    supabase/functions/before-user-created/index.ts
    import { Webhook } from "https://esm.sh/standardwebhooks@1.0.0";
    
    const hookSecret = (Deno.env.get("BEFORE_USER_CREATED_HOOK_SECRET") ?? "").replace("v1,whsec_", "");
    const apiKey = Deno.env.get("SIMPLEVERIFIER_KEY") ?? "";
    const dryRun = Deno.env.get("SIGNUP_GUARD_DRY_RUN") === "1";
    
    function json(body: unknown, status: number) {
      return new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json" } });
    }
    
    function blockMessage(reasonCode: string, reason: string): string {
      if (reasonCode === "disposable") return "Please use a permanent email address, not a temporary inbox.";
      const hint = reason.match(/did you mean ([^?\s]+)\??/i)?.[1];
      if (hint) return `Check the spelling of your email address. Did you mean ${hint}?`;
      return "This email address can't receive mail. Please check it.";
    }
    
    Deno.serve(async (req) => {
      const payload = await req.text();
      let email = "";
      try {
        const { user } = new Webhook(hookSecret).verify(payload, Object.fromEntries(req.headers)) as { user: { email?: string } };
        email = user.email ?? "";
      } catch {
        return json({ error: { http_code: 400, message: "Invalid hook signature" } }, 400);
      }
      if (!email || !apiKey) return json({}, 200); // phone signups, or no key set: allow
    
      try {
        const res = await fetch("https://simpleverifier.com/api/v1/verify", {
          method: "POST",
          headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json" },
          body: JSON.stringify({ email }),
          signal: AbortSignal.timeout(4000), // HTTP hooks must answer within 5 s
        });
        if (!res.ok) return json({}, 200); // fail open
        const result = await res.json();
        if (result.action !== "do_not_send") return json({}, 200); // valid, risky, retry_later: allow
        const message = blockMessage(result.reason_code ?? "", result.reason ?? "");
        if (dryRun) {
          console.log("dry run, would block", result.reason_code);
          return json({}, 200);
        }
        return json({ error: { http_code: 400, message } }, 400);
      } catch {
        return json({}, 200); // timeout: fail open
      }
    });
  3. 3. Deploy and switch the hook on

    Hooks run before a JWT exists, so deploy without JWT verification; the signature check above is what keeps strangers out. Then select the function under Authentication > Hooks > Before User Created and save.

    supabase functions deploy before-user-created --no-verify-jwt
  4. 4. Flag the warn cases after signup (optional)

    The hook can only allow or reject. To hold free credits for role inboxes, catch-all domains and invented-looking addresses, call the same API from your onboarding code and store reason_code and likely_invented on your profiles table.

More on calling the API from this stack (retries, status handling, tests): API guide. Every field is described in the API docs.

Common questions

Does this work with Google or GitHub sign-in?
The hook runs for every new user, including OAuth sign-ups. Those addresses come from the provider and are rarely disposable, so the check almost always allows them. If you want to skip them, log one payload to see which provider field your project sends and return {} for anything other than email.
What if the check takes longer than the hook allows?
The snippet stops waiting after 4 seconds and lets the signup through. Disposable, format, no-mail-server and typo answers are fast; a slow corporate mail server is the usual reason a check runs long, and those addresses are rarely abuse.
Which plan includes the API?
Pro ($29.99/month, 10,000 calls a month included), Business and Agency. Each address checked is one call. Starter and Growth have no API access.
How do I try it without blocking anyone?
Set SIGNUP_GUARD_DRY_RUN=1. The code logs what it would block and lets everyone through. Read the logs for a few days, then remove the variable.

Other recipes

Clean your next list before it bounces

100 free email checks when you sign in with Google, no card. Then monthly plans from $9, or one flat price with fair use, never cut off.

100 free checks, no card Cancel anytime Secure payment by Stripe