Block Invalid and Disposable Signups in Supabase Auth With a Hook
To block invalid and disposable email signups in Supabase, enable the Before User Created auth hook and point it at an Edge Function. The function checks the hook's signature, sends the new user's email to a verification API, and returns an error for invalid results. Supabase then refuses the signup and passes your message to the client. Cap the API call at about 3.5 seconds and let the signup through on any timeout or error. Supabase says HTTP hooks should finish within 5 seconds.
This guide builds that function step by step, using SimpleVerifier's API. The pattern works with any verification API that returns a status.
Why check at the Auth layer
You can verify an address in your signup form's server code instead. Our Next.js signup guide does exactly that. But with Supabase, signups often go straight from the browser to Auth through supabase.auth.signUp(). There is no server code of yours in that path.
The hook closes the gap. Supabase's Before User Created hook documentation describes it as running just before Supabase Auth creates a new user. It can allow the signup or reject it with an error. Supabase lists blocking disposable email domains as one use.
Two practical gains follow:
- No junk rows. Rejected users never reach
auth.users. Supabase notes the user does not exist in Postgres yet when the hook runs. - Fewer wasted confirmation emails. Supabase's custom SMTP guide says a custom SMTP server starts with a rate limit of 30 messages an hour until you change it. Every confirmation sent to a dead or throwaway address uses part of that budget.
How the hook works
Supabase's Auth Hooks overview lists Before User Created among the hooks on the Free and Pro plans. Each hook can be a Postgres function or an HTTP endpoint.
| Detail | What Supabase documents |
|---|---|
| Hook types | Postgres function or HTTP endpoint |
| Time limit | "Postgres Hooks have 2 seconds to complete processing while HTTP Hooks should complete in 5 seconds" |
| Retries | HTTP only, on 429 or 503 with a retry-after header, up to three times, within the same 5-second budget |
| Signature | Standard Webhooks headers: webhook-id, webhook-timestamp, webhook-signature |
| Allow | HTTP 200 with {}, or 204 with no body |
| Reject | A 4xx status and { "error": { "message": "...", "http_code": 400 } } |
| Content type | Responses must be application/json, or Auth returns an Internal Server Error |
The payload has two parts. metadata holds the event uuid, time, name and ip_address. user holds the proposed record, including email, phone, app_metadata (with provider) and user_metadata.
The timing problem, and how to design around it
A live mailbox check opens an SMTP conversation with the recipient's mail server. Most answer in a few seconds. Some are slow. Our API docs recommend a client timeout of about 30 seconds for that reason.
Supabase gives the whole hook 5 seconds. So you cannot wait for every answer. The design choice is what to do when the clock runs out.
| Option | Effect when the API is slow | Verdict |
|---|---|---|
| Wait the full 30 seconds | The hook times out and the signup fails | Breaks real signups |
| Fail closed (reject on timeout) | Real people with slow mail servers are refused | Breaks real signups |
| Fail open (allow on timeout) | A few unchecked addresses get through and are logged | Recommended |
Fail open is the only option that never costs you a real user. The cheap failures are still usually caught inside the window. Bad syntax, a domain with no mail server and a known disposable domain don't need a mailbox conversation at all. Anything that slips through is logged, and you can re-check it in bulk later.
The code below sets the API timeout to 3,500 ms. That leaves room for the signature check, the response, and network time to Supabase.
Step 1: Create the Edge Function and secrets
Create the function with the Supabase CLI:
supabase functions new before-user-created
The hook calls the function with a signature, not a user's JWT. Supabase's function configuration docs say Edge Functions require a valid JWT by default and show how to turn that off per function. Add this to supabase/config.toml:
[functions.before-user-created]
verify_jwt = false
The Standard Webhooks signature replaces the JWT check, so do not skip Step 3.
Next, the secrets. In the dashboard, open Authentication > Hooks, add a Before User Created hook of type HTTPS, and generate a secret. It looks like v1,whsec_.... Put it and your verification key in a .env file:
BEFORE_USER_CREATED_HOOK_SECRET="v1,whsec_..."
SIMPLEVERIFIER_KEY="sv_live_..."
Then upload both. Supabase's secrets guide documents the command and says functions read a new secret immediately, with no redeploy:
supabase secrets set --env-file .env
On SimpleVerifier, API keys start with sv_live_ and are shown once. API access comes with a subscription. Pay-as-you-go packs cover CSV uploads only.
Step 2: The Edge Function
Replace supabase/functions/before-user-created/index.ts with this:
import { Webhook } from "https://esm.sh/standardwebhooks@1.0.0";
const VERIFY_URL = "https://simpleverifier.com/api/v1/verify";
const VERIFY_TIMEOUT_MS = 3500;
interface HookUser {
email?: string;
app_metadata?: { provider?: string };
}
interface Verdict {
status: "valid" | "risky" | "invalid";
reason: string;
}
const MESSAGES: Record<string, string> = {
"Invalid format": "That email address doesn't look right. Please check it.",
"No mail server": "That email domain can't receive mail. Please check the spelling.",
"Disposable address": "Please sign up with a permanent email address.",
"Mailbox not found": "That mailbox doesn't exist. Please check the address.",
};
const hookSecret = (Deno.env.get("BEFORE_USER_CREATED_HOOK_SECRET") ?? "").replace("v1,whsec_", "");
function json(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { "Content-Type": "application/json" },
});
}
const allow = () => json({});
function reject(reason: string): Response {
const message = MESSAGES[reason] ?? "Please use a different email address.";
return json({ error: { message, http_code: 400 } }, 400);
}
async function verifyEmail(email: string): Promise<Verdict | null> {
const apiKey = Deno.env.get("SIMPLEVERIFIER_KEY");
if (!apiKey) {
console.error("SIMPLEVERIFIER_KEY is not set; allowing signup");
return null;
}
try {
const response = await fetch(VERIFY_URL, {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ email }),
signal: AbortSignal.timeout(VERIFY_TIMEOUT_MS),
});
if (!response.ok) {
console.warn(`verification skipped: HTTP ${response.status}`);
return null;
}
return (await response.json()) as Verdict;
} catch (error) {
console.warn(`verification skipped: ${String(error)}`);
return null;
}
}
Deno.serve(async (req) => {
const payload = await req.text();
const headers = Object.fromEntries(req.headers);
let user: HookUser;
try {
({ user } = new Webhook(hookSecret).verify(payload, headers) as { user: HookUser });
} catch {
return json({ error: { message: "Invalid request format" } }, 400);
}
const email = (user.email ?? "").trim().toLowerCase();
if (!email || user.app_metadata?.provider !== "email") return allow();
const verdict = await verifyEmail(email);
if (!verdict) return allow();
if (verdict.status === "invalid") {
console.log(`rejected signup: ${verdict.reason}`);
return reject(verdict.reason);
}
if (verdict.status === "risky") {
console.log(`risky signup allowed: ${verdict.reason}`);
}
return allow();
});
The import and the secret handling follow the example in Supabase's own hook docs. The rest is ours.
What each part does
| Part | Job |
|---|---|
Webhook(...).verify |
Rejects any request not signed with your hook secret |
| Provider check | Skips OAuth, phone and other non-email signups |
AbortSignal.timeout(3500) |
Stops waiting on the API inside Supabase's 5-second budget |
!response.ok branch |
Fails open on 401, 402, 429, 503 and anything else |
MESSAGES map |
Turns the API's reason into a message a person can act on |
The function never returns 429 or 503 itself. Supabase retries only on those codes, and retries would eat the same 5-second budget.
The API's answers
The verification response looks like this:
{
"email": "jane@company.com",
"status": "invalid",
"reason": "Disposable address",
"mailbox_checked": false
}
status is valid, risky or invalid. The API docs list every reason. The four invalid reasons are the ones in the MESSAGES map: Invalid format, No mail server, Disposable address and Mailbox not found.
Step 3: Deploy and enable the hook
Deploy the function:
supabase functions deploy before-user-created
Its URL is https://<project-ref>.supabase.co/functions/v1/before-user-created. Paste it into the hook settings under Authentication > Hooks and enable the hook.
Keep the API key in Edge Function secrets only. Never put it in the client or in NEXT_PUBLIC_ variables. Anyone who can read a key can use your plan.
Step 4: Show the message in your app
When the hook rejects a signup, Supabase passes your message back to the client that tried to sign up. Display it next to the email field:
const { error } = await supabase.auth.signUp({ email, password });
if (error) {
setEmailError(error.message);
return;
}
"That mailbox doesn't exist. Please check the address." is far more useful than a generic failure. Most rejected signups at this stage are typos, not attackers. Our guide to catching email typos at signup covers suggesting the fix, for example gmial.com to gmail.com.
What to do with risky results
risky covers several different cases, and none of them justifies refusing a signup:
| Reason | What it usually means | At signup |
|---|---|---|
| Accepts all addresses | The domain accepts every address, so the mailbox can't be confirmed. See catch-all domains | Allow |
| Role-based address | A shared inbox such as info@ | Allow; maybe ask for a personal work address later |
| Provider hides mailbox status | The provider does not reveal mailbox status | Allow |
| Server would not confirm | The mail server refused to answer | Allow |
| Mailbox check unavailable | The check could not run | Allow |
The function logs these. If you want them in your data, add a column to a profiles table and write the reason from your app after signup.
Worked example: three signups
Take three signups against the function above. The outcomes are illustrative, not measured.
jane@gmial.com. The domain has no mail server. The API answersinvalid, "No mail server", well inside the timeout. The hook returns 400 with "That email domain can't receive mail. Please check the spelling." No user row is created. Jane fixes the typo and signs up.x7k2@a-throwaway-domain.example. The domain is on the disposable list. The API answersinvalid, "Disposable address". The signup is refused with "Please sign up with a permanent email address."sam@slow-mail-server.example. The mail server takes longer than 3.5 seconds to answer. The fetch aborts, the function logs "verification skipped" and returns{}. Sam signs up normally. Sam's address is checked later in a bulk pass.
The third case is the cost of fail-open. It is a small one. The alternative is refusing a real person because their company's mail server was slow.
Testing before you rely on it
Run these five signups against a staging project:
- An address you own. It should succeed.
- A made-up address at gmail.com. It should be refused with "Mailbox not found".
- An address at a domain with no MX record. It should be refused with "No mail server".
- A Google sign-in. It should succeed without a verification call in the logs.
- The same signup with
SIMPLEVERIFIER_KEYunset. It should succeed and log an error. This proves the function fails open.
Then check Edge Functions > Logs for the "rejected", "risky" and "skipped" lines.
Common problems
| Symptom | Likely cause | Fix |
|---|---|---|
| Every signup fails with a server error | Response missing the JSON content type | Return every response through the json() helper |
| Every signup fails with "Invalid request format" | Wrong hook secret, or prefix not stripped | Regenerate the secret and run supabase secrets set again |
| Hook calls rejected with 401 | JWT check still on | Set verify_jwt = false and redeploy |
| Log shows HTTP 401 or 402 from the API | Key wrong, or subscription not active | Check the key and plan; packs have no API access |
| Log shows many timeouts | Slow mail servers | Expected; re-check those addresses in bulk |
When to verify in bulk instead
The hook protects new signups. It does nothing for the users you already have. For those, export the emails from auth.users, upload the CSV, and act on the invalid file. Our guide on removing invalid emails from a CSV walks through it. If you would rather script a recurring pass, the Node.js and Python SDKs include a bulk helper that retries on rate limits. Each key allows 120 calls a minute.
For a wider view of signup abuse beyond email checks, such as honeypots and rate limits, see how to stop fake signups. For the same problem without Supabase, see blocking disposable emails on a signup form.
What it costs
On SimpleVerifier, API access is included in the Pro plan at $29.99 a month, with unlimited email verification and no per-call credits. Email + Phone at $49.99 a month adds live carrier lookups through the same API. Pay-as-you-go packs ($9 for 2,500 or $19 for 10,000 emails) cover CSV uploads only, which suits the one-off cleanup of existing users. See pricing.
The short version
Enable Supabase's Before User Created hook as an HTTP hook on an Edge Function with verify_jwt off. Verify the Standard Webhooks signature, skip non-email providers, and call the verification API with the key from Edge Function secrets. Abort after 3.5 seconds. Reject invalid with a clear message, allow risky and log it, and allow everything on any error. Clean existing users with a bulk upload.
Common questions
Can Supabase block disposable email addresses on signup?
Not by itself. Supabase Auth's Before User Created hook lets you run your own check before a user is created and reject the signup with an error. Supabase's own docs give blocking disposable domains as an example use. The hook calls your code, and your code decides, for example by calling an email verification API.
Should I use a Postgres hook or an HTTP hook for email verification?
An HTTP hook on an Edge Function. Verification means calling an external API, which a request handler does naturally. Supabase also gives Postgres hooks 2 seconds to finish and HTTP hooks 5 seconds, and the extra time matters when you wait on a network call.
What happens if the verification API is slow or down?
With the code in this guide, the signup goes through. The Edge Function aborts the API call after 3.5 seconds and returns an empty object, which Supabase treats as allow. The event is logged so you can re-check those addresses later.
Does this replace Supabase's email confirmation?
No. Confirmation proves the person controls the inbox. Verification at signup only stops addresses that cannot receive mail, or throwaway ones, from creating accounts. Keep both. Verification also means fewer confirmation emails sent to dead addresses.
Will this check Google or GitHub signups too?
The hook runs for those signups as well, but the code skips them. It only verifies users whose app_metadata.provider is email. An address from an OAuth provider comes from an account the person just signed in to.
Verify unlimited addresses for $29.99/month
Real SMTP mailbox checks. No credits, no per-email fees.
Get Started