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. 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. 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. 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-jwt4. 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
- Block fake and disposable signups in Clerk
- Block fake and disposable signups in Firebase Auth
- Block fake and disposable signups in Next.js
- Express middleware that blocks fake signups
- Block fake signups at the edge with a Cloudflare Worker
- Filter fake form signups in Zapier or n8n
- Filter fake Webflow and Framer form signups