Guides /

Supabase Auth

TS before-user-created hook

An Edge Function behind Supabase's before-user-created hook. It rejects bad addresses before the user row exists, and the message reaches your signup form.

The code

1 Write the function

supabase/functions/before-user-created/index.ts

import { Webhook } from "https://esm.sh/standardwebhooks@1.0.0";

const hook = new Webhook(
  Deno.env.get("BEFORE_USER_CREATED_HOOK_SECRET")!.replace("v1,whsec_", ""),
);

// Supabase needs application/json on every answer.
const reply = (body: unknown, status = 200) =>
  new Response(JSON.stringify(body), {
    status,
    headers: { "Content-Type": "application/json" },
  });

// HTTP 200 with an error object is what rejects with your message.
const reject = (message: string) =>
  reply({ error: { message, http_code: 422 } });

Deno.serve(async (req) => {
  const payload = await req.text();
  let email = "";
  try {
    const event = hook.verify(payload, Object.fromEntries(req.headers));
    email = (event as { user: { email?: string } }).user.email ?? "";
  } catch {
    return reply({ error: "Bad signature" }, 401);
  }
  if (!email) return reply({}); // phone signups have no address

  try {
    const res = await fetch(
      "https://mailvet.dev/v1/check?email=" + encodeURIComponent(email),
      {
        headers: { Authorization: `Bearer ${Deno.env.get("MAILVET_KEY")}` },
        signal: AbortSignal.timeout(4000),
      },
    );
    if (!res.ok) throw new Error(`HTTP ${res.status}`);
    const check = await res.json();

    if (check.did_you_mean) {
      const local = email.slice(0, email.lastIndexOf("@"));
      return reject(`Did you mean ${local}@${check.did_you_mean}?`);
    }
    if (check.verdict === "invalid") {
      return reject("That address can't receive mail.");
    }
    if (check.verdict === "risky" && check.disposable) {
      return reject("Please use a permanent address, not a throwaway one.");
    }
  } catch (err) {
    console.error("mailvet: no answer", err); // let the signup through
  }
  return reply({});
});

2 Deploy it without JWT checks

supabase functions deploy before-user-created --no-verify-jwt
supabase secrets set MAILVET_KEY=mv_live_your_key

The hook runs before any user exists, so there's no JWT to check. The signature check in the code does that job.

3 Turn on the hook

# Dashboard: Authentication > Hooks > Before User Created > HTTP
# URL: https://<project-ref>.supabase.co/functions/v1/before-user-created
# Generate the secret there, then store it:
supabase secrets set 'BEFORE_USER_CREATED_HOOK_SECRET=v1,whsec_...'

What to do with the answer

did_you_mean, invalid and disposable return the error object, so no user is created and the message goes back to the client. Role addresses and a risky from a DNS failure return {} and carry on.

  • valid Let it through.
  • risky Read disposable. If it’s true, reject. If false, it’s a role address or DNS couldn’t be reached, so let it through.
  • invalid Bad format, or the domain has no mail server. Reject.

did_you_mean is empty or holds a domain, such as gmail.com for gamil.com. It doesn’t change the verdict. A known typo can come back valid because squatters give those domains a mail server, so check it first. See the docs for every field.

If MailVet is down, the signup goes through

The catch logs and falls through to {}, which lets the signup proceed. The 4-second timeout matters: Supabase gives HTTP hooks a 5-second budget, and running past it fails the hook, so the signup fails too.

A 401 or a 429 takes the same path, so a bad key or a used-up quota means addresses pass unchecked. Watch your logs for it.

Watch out

  • Supabase's hook page shows rejections with HTTP 400. Its general hook docs say 400 is treated as an internal error, and the Supabase Auth source only reads the error body on 200 or 202. So this code returns 200 with the error object, which should give your user the real message instead of a generic 500. We read the source but didn't run this against a live project, so try one blocked signup before you ship.
  • A signature failure answers 401, which blocks the signup. That's correct for a forged call, but it also means a wrong secret blocks everyone. Test right after setup.
  • The hook covers every new user, OAuth included. It takes one URL, so merge this with any signup policy you already have there.

Sources

Compared with these on November 5, 2026. If they’ve changed since, trust them over this page.

Other guides

Get API key