All posts

Block Invalid and Disposable Signups in Supabase Auth With a Hook

··10 min read

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.

  1. jane@gmial.com. The domain has no mail server. The API answers invalid, "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.
  2. x7k2@a-throwaway-domain.example. The domain is on the disposable list. The API answers invalid, "Disposable address". The signup is refused with "Please sign up with a permanent email address."
  3. 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_KEY unset. 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