Block disposable emails in Supabase Auth with a free hook (no API key)

Block throwaway signups in Supabase Auth with a before-user-created hook: a free Edge Function or a SQL list, both tested, and the 200 answer Auth expects.

To block disposable emails in Supabase Auth, turn on the before-user-created hook and point it at a check of the address's domain. Supabase runs the hook before it creates any user. Below are two tested versions with no API key: an Edge Function that asks FFraud's free API, and a Postgres function that reads FFraud's MIT list from a table. Both refuse by returning an error object. The Edge Function sends it with HTTP 200, because Supabase Auth turns a 400 into a generic 500.

#Which version should you use?

Supabase lets the hook be an HTTP endpoint or a Postgres function (Supabase docs). The two versions differ like this:

QuestionEdge FunctionPostgres function
What decidesFFraud's free API, by domainFFraud's MIT list in a table
Time the check takesA median of 197 ms in our test, one API call11 to 15 microseconds in our test
What leaves your projectThe domain, sent as user@ and the domainNothing
Relay addresses such as Hide My EmailAccepted, the API answers aliasRefused, except through Sign in with Apple
When the check breaksThe signup goes onSignups fail with a 500 until you fix it
UpkeepNoneA daily refresh of the table

Pick the Edge Function if relay users matter to you or you want nothing to maintain. Pick the Postgres function if no request may leave your project at signup.

#How do you build the Edge Function?

supabase/functions/before-user-created-hook/index.ts
// Supabase Auth "before user created" hook: refuse throwaway email addresses with FFraud's free API.
// No key and no imports. Deploy with --no-verify-jwt; the Standard Webhooks signature protects the hook.
const API_URL = "https://api.ffraud.com/public/email/check";
// Relay services such as Apple's Hide My Email forward to an inbox a real person reads. Set to false to refuse them too.
const ACCEPT_RELAYS = true;
const MESSAGE = "Please use an email address you will keep. Throwaway inboxes are not accepted.";

const bytes = (base64: string) => Uint8Array.from(atob(base64), (c) => c.charCodeAt(0));
const json = (status: number, body: unknown) =>
  new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json" } });

// Standard Webhooks: HMAC-SHA256 over "id.timestamp.body", and a timestamp within 5 minutes.
async function signed(req: Request, body: string): Promise<boolean> {
  const id = req.headers.get("webhook-id");
  const timestamp = req.headers.get("webhook-timestamp");
  const signatures = req.headers.get("webhook-signature");
  const secret = Deno.env.get("BEFORE_USER_CREATED_HOOK_SECRET")?.replace("v1,whsec_", "");
  if (!id || !timestamp || !signatures || !secret) return false;
  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;
  try {
    const key = await crypto.subtle.importKey("raw", bytes(secret), { name: "HMAC", hash: "SHA-256" }, false, ["verify"]);
    const data = new TextEncoder().encode(`${id}.${timestamp}.${body}`);
    for (const signature of signatures.split(" ")) {
      const [version, value] = signature.split(",");
      if (version === "v1" && value && (await crypto.subtle.verify("HMAC", key, bytes(value), data))) return true;
    }
  } catch {
    // a secret or a signature that is not base64
  }
  return false;
}

// "temp", "alias" or "normal" for the domain, or null when the API cannot answer in 2 seconds.
// The type depends on the domain alone, so the API gets user@ and the domain, never the address.
async function emailType(domain: string): Promise<string | null> {
  try {
    const res = await fetch(API_URL, {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ email: `user@${domain}` }),
      signal: AbortSignal.timeout(2000),
    });
    if (!res.ok) return null;
    const type = (await res.json()).email_type;
    return typeof type === "string" ? type : null;
  } catch {
    return null;
  }
}

export async function handler(req: Request): Promise<Response> {
  const body = await req.text();
  if (!(await signed(req, body))) return json(401, { error: { http_code: 401, message: "Invalid hook signature" } });
  const email = String(JSON.parse(body).user?.email ?? "").trim().toLowerCase();
  const at = email.lastIndexOf("@");
  if (at < 1) return json(200, {}); // a phone signup has no email address
  const type = await emailType(email.slice(at + 1));
  if (type === "temp" || (type === "alias" && !ACCEPT_RELAYS)) {
    // Answer 200: Supabase Auth reads the error object only from a 200 or 202, and turns a 400 into a 500.
    return json(200, { error: { http_code: 400, message: MESSAGE } });
  }
  return json(200, {}); // a normal address, a relay, or no answer from the API: the signup goes on
}

Deno.serve(handler);
  • Answer 200 to refuse. Supabase Auth reads the error object only from a 200 or 202 answer, and it turns a 400 into a 500 that says "Invalid payload sent to hook" (Supabase Auth 2.197.0 source). Two of Supabase's own examples refuse with 400 or 403, and an issue about the 400 has been open since September 2025.
  • Check the signature. Supabase signs each call by the Standard Webhooks rules: HMAC-SHA256 over the id, the timestamp and the body. With more than one secret it sends several signatures, joined with a comma and a space, and the function accepts any one that matches.
  • Only the domain leaves. The type of an address depends on its domain, so the API gets user@ and the domain.
  • Relays pass. Set ACCEPT_RELAYS to false to refuse them as well.
  • The API gets 2 seconds. Supabase gives an HTTP hook 5 (Supabase docs), and without an answer the signup goes on.

To deploy it, open the Auth Hooks page of your dashboard and add a Before User Created hook that calls an HTTPS endpoint. Its URL is https://<project-ref>.supabase.co/functions/v1/before-user-created-hook. Generate the secret there, then set it and deploy:

set the secret and deploy
# .env
BEFORE_USER_CREATED_HOOK_SECRET="v1,whsec_<the secret from the Auth Hooks page>"

supabase secrets set --env-file .env
supabase functions deploy before-user-created-hook --no-verify-jwt

--no-verify-jwt turns off the JWT check, because Supabase calls the hook before any user has a token. The signature takes its place (Supabase docs).

#How do you build the Postgres function?

supabase/migrations/20261026000000_block_disposable_email.sql
-- Throwaway email domains for the before-user-created hook, loaded from FFraud's MIT list.
create table if not exists public.disposable_email_domains (domain text primary key);
alter table public.disposable_email_domains enable row level security;

create or replace function public.hook_block_disposable_email(event jsonb)
returns jsonb
language plpgsql
stable
as $$
declare
  email text := lower(trim(coalesce(event->'user'->>'email', '')));
  labels text[] := string_to_array(substring(email from '[^@]*$'), '.');
begin
  -- No address (a phone signup), or Sign in with Apple, whose relay addresses a real person reads.
  if position('@' in email) = 0 or event->'user'->'app_metadata'->>'provider' = 'apple' then
    return '{}'::jsonb;
  end if;
  -- The domain or any parent of it: inbox.mailinator.com matches mailinator.com.
  for i in 1 .. coalesce(array_length(labels, 1), 0) - 1 loop
    if exists (select 1 from public.disposable_email_domains d where d.domain = array_to_string(labels[i:], '.')) then
      return jsonb_build_object('error', jsonb_build_object(
        'http_code', 400,
        'message', 'Please use an email address you will keep. Throwaway inboxes are not accepted.'));
    end if;
  end loop;
  return '{}'::jsonb;
end;
$$;

-- Supabase Auth calls the hook as supabase_auth_admin; nobody else may run it.
grant usage on schema public to supabase_auth_admin;
grant select on public.disposable_email_domains to supabase_auth_admin;
drop policy if exists "Auth reads the disposable list" on public.disposable_email_domains;
create policy "Auth reads the disposable list" on public.disposable_email_domains
  for select to supabase_auth_admin using (true);
grant execute on function public.hook_block_disposable_email to supabase_auth_admin;
revoke execute on function public.hook_block_disposable_email from authenticated, anon, public;
  • Return the error object. An exception raised inside the function reaches your app as a 500 that says "Error running hook URI", so the function returns the object instead (Supabase Auth 2.197.0 source).
  • Parent domains count. inbox.mailinator.com matches mailinator.com, through the primary key's index.
  • Sign in with Apple passes. Apple gives each app a privaterelay.appleid.com address when a person picks Hide My Email (Apple Support). The list holds that domain, so the function skips signups whose provider is apple. Other relays typed into an email signup, such as duck.com, are refused.
  • Narrow grants. Supabase Auth calls the hook as supabase_auth_admin, which needs the schema, the table, the function and a row level security policy. anon and authenticated get none of them.

Run the migration, then turn the hook on in the Auth Hooks page as a Postgres hook with public.hook_block_disposable_email. Fill the table with this script, once now and then once a day from cron or your CI:

refresh-disposable-domains.sh
#!/bin/sh
# Refresh public.disposable_email_domains from FFraud's MIT list. Run it once a day from cron or CI.
# DATABASE_URL is your project's connection string (Supabase dashboard, Connect).
set -eu
cd "$(mktemp -d)"
curl -fsSL --max-time 60 -o disposable-email-domains.txt \
  https://raw.githubusercontent.com/FFraud-com/disposable-email-domains/main/disposable-email-domains.txt
psql "$DATABASE_URL" -v ON_ERROR_STOP=1 <<'SQL'
begin;
create temp table incoming (domain text) on commit drop;
\copy incoming from 'disposable-email-domains.txt'
delete from incoming where domain is null or trim(domain) = '' or domain like '#%';
do $$ begin
  if (select count(*) from incoming) < 100000 then
    raise exception 'only % domains in the download, keeping the current list', (select count(*) from incoming);
  end if;
end $$;
delete from public.disposable_email_domains;
insert into public.disposable_email_domains (domain)
  select distinct lower(rtrim(trim(domain), '.')) from incoming;
commit;
SQL

The script refuses a download under 100,000 domains, and it swaps the rows inside one transaction. Signups keep reading the old rows until it commits, since in PostgreSQL reading never blocks writing (PostgreSQL docs).

#What does each check cost a signup?

We timed both versions on October 26, 2026. The Postgres function ran 10,000 times on PostgreSQL 17.6, and the Edge Function made 12 calls with one API request each:

Time the email check adds to one signupMilliseconds per check, October 26, 2026. The Edge Function was timed from our test machine in Germany, not on Supabase's edge.
  1. Postgres function, listed domain0.01 ms
  2. Postgres function, unlisted subdomain0.02 ms
  3. Edge Function, fastest of 12124 ms
  4. Edge Function, median of 12197 ms
  5. Edge Function, slowest of 12464 ms

Source: FFraud test on PostgreSQL 17.6 and Node.js 22.19, October 26, 2026

The table and its index took 20 MB for 224,174 domains, and the refresh took 1.7 seconds. Supabase gives a Postgres hook 2 seconds (Supabase docs), so the lookup leaves almost all of that time unused.

#How do you test it?

The Edge Function ran in Node.js 22, which runs TypeScript files by stripping their types. A stand-in supplied Deno.env and Deno.serve, and each request was signed the way Supabase Auth signs a hook. The second line of each case shows what Supabase Auth makes of the answer, following its source:

Edge Function test, October 26, 2026, 01:03 UTC
Node v22.19.0 2026-10-26T01:03:31.423Z | Deno.serve got the handler: true
test@mailinator.com                            -> 200 {"error":{"http_code":400,"message":"Please use an email address you will keep. Throwaway inboxes are not accepted."}}
                                                  Auth: signup refused: 400 "Please use an email address you will keep. Throwaway inboxes are not accepted."; 661 ms, API calls: 1
user@inbox.mailinator.com                      -> 200 {"error":{"http_code":400,"message":"Please use an email address you will keep. Throwaway inboxes are not accepted."}}
                                                  Auth: signup refused: 400 "Please use an email address you will keep. Throwaway inboxes are not accepted."; 262 ms, API calls: 1
test@privaterelay.appleid.com (Sign in with Apple) -> 200 {}
                                                  Auth: signup goes on; 164 ms, API calls: 1
Team@FFraud.com                                -> 200 {}
                                                  Auth: signup goes on; 256 ms, API calls: 1
phone signup, no email                         -> 200 {}
                                                  Auth: signup goes on; 0 ms, API calls: 0
two signatures, after a secret rotation        -> 200 {}
                                                  Auth: signup goes on; 219 ms, API calls: 1
wrong signature                                -> 401 {"error":{"http_code":401,"message":"Invalid hook signature"}}
                                                  Auth: 500 "Hook requires authorization token"; 0 ms, API calls: 0
signed 10 minutes ago                          -> 401 {"error":{"http_code":401,"message":"Invalid hook signature"}}
                                                  Auth: 500 "Hook requires authorization token"; 0 ms, API calls: 0
for comparison: a 400 answer, as in the docs   -> 400 {"error":{"http_code":400,"message":"Please sign up with a company email address."}}
                                                  Auth: 500 "Invalid payload sent to hook"; 0 ms, API calls: 0
what the API received: {"email":"user@mailinator.com"} {"email":"user@inbox.mailinator.com"} {"email":"user@privaterelay.appleid.com"} {"email":"user@ffraud.com"} {"email":"user@ffraud.com"}
API down, test@mailinator.com                  -> 200 {}
                                                  Auth: signup goes on; 0 ms, API calls: 1
ACCEPT_RELAYS false, test@privaterelay.appleid.com -> 200 {"error":{"http_code":400,"message":"Please use an email address you will keep. Throwaway inboxes are not accepted."}}
                                                  Auth: signup refused: 400 "Please use an email address you will keep. Throwaway inboxes are not accepted."; 166 ms, API calls: 1
hook time with one API call, 12 runs: median 197 ms, fastest 124 ms, slowest 464 ms

The Postgres function ran on a throwaway PostgreSQL 17.6 cluster, with Supabase's three roles created as stand-ins and the hook called as supabase_auth_admin:

Postgres function test, October 26, 2026, 01:01 UTC
postgres (PostgreSQL) 17.6, 2026-10-26T01:01:18Z
psql:project/supabase/migrations/20261026000000_block_disposable_email.sql:33: NOTICE:  policy "Auth reads the disposable list" for relation "public.disposable_email_domains" does not exist, skipping
migration applied
BEGIN
CREATE TABLE
COPY 224174
DELETE 0
DO
DELETE 0
INSERT 0 224174
COMMIT
refresh: 224174 domains in 1721 ms, table and index 20 MB
guard: only 2000 domains in the download, keeping the current list
after the short download: 224174 domains
hook("test@mailinator.com", provider email) as supabase_auth_admin -> {"error": {"message": "Please use an email address you will keep. Throwaway inboxes are not accepted.", "http_code": 400}}
hook("user@inbox.mailinator.com", provider email) as supabase_auth_admin -> {"error": {"message": "Please use an email address you will keep. Throwaway inboxes are not accepted.", "http_code": 400}}
hook("Team@FFraud.com", provider email) as supabase_auth_admin -> {}
hook("test@privaterelay.appleid.com", provider apple) as supabase_auth_admin -> {}
hook("test@privaterelay.appleid.com", provider email) as supabase_auth_admin -> {"error": {"message": "Please use an email address you will keep. Throwaway inboxes are not accepted.", "http_code": 400}}
hook("test@duck.com", provider email) as supabase_auth_admin -> {"error": {"message": "Please use an email address you will keep. Throwaway inboxes are not accepted.", "http_code": 400}}
hook("", provider phone) as supabase_auth_admin -> {}
anon calls the hook -> permission denied for function hook_block_disposable_email
anon reads the table -> permission denied for table disposable_email_domains
hook on a listed domain: 10.6 microseconds a call (10000 calls)
hook on an unlisted subdomain (3 lookups): 15.2 microseconds a call (10000 calls)
plan: Index Only Scan using disposable_email_domains_pkey on disposable_email_domains (actual rows=1 loops=1)   Index Cond: (domain = 'mailinator.com'::text)   Heap Fetches: 1

Supabase itself was not installed for these tests, so try both versions on a staging project before you ship them.

Whichever you pick, our guide to email aliases and Hide My Email explains why most sites should accept relay addresses.

#Questions people ask

How do I block disposable emails in Supabase?

Turn on the before-user-created hook and point it at one of the two functions above. Supabase runs it before it creates a user, so every new account passes through it.

Why does my before-user-created hook return "Invalid payload sent to hook"?

Your hook answered with HTTP 400. Supabase Auth reads the error object only from a 200 or 202 answer and turns a 400 into this 500. Answer 200 with {"error": {"http_code": 400, "message": "..."}}.

Should the hook be an Edge Function or a Postgres function?

The Edge Function asks the free API, accepts relay addresses and needs no upkeep. The Postgres function sends nothing out and answers in microseconds, and its table needs a daily refresh.

Does the hook run for Sign in with Apple and other OAuth signups?

Yes. Supabase's own examples read app_metadata.provider in this hook to treat OAuth signups apart. The Postgres function above uses it to let Sign in with Apple through.

Can I block disposable emails in Supabase without an API key?

Yes. FFraud's free email check needs no key, and the Postgres function needs no API at all, only the MIT list.

Should I also check the domain's MX records?

An MX check answers another question: whether the domain takes mail at all. Throwaway services do, so they pass it: on October 26, 2026, mailinator.com listed two MX records. The check does stop a domain like example.com, whose null MX says it accepts no mail (RFC 7505). The MX lookup shows any domain's records.

What happens if the check is slow or down?

The Edge Function gives the API 2 seconds and then lets the signup through. Supabase gives an HTTP hook 5 seconds and a Postgres hook 2 (Supabase docs).

To check one address by hand, paste it into the free disposable email checker, or browse the disposable email domains list. The API behind the Edge Function is described on the temporary email API page.

Sources

  1. Before User Created Hook, Supabase docs
  2. Auth Hooks, Supabase docs
  3. internal/hooks/hookshttp/hookshttp.go, v2.197.0, Supabase Auth on GitHub, September 9, 2026
  4. internal/hooks/v0hooks/manager.go, v2.197.0, Supabase Auth on GitHub, September 9, 2026
  5. Issue 2235: before_user_created Auth Hook Returns "Invalid payload sent to hook", Supabase Auth on GitHub, September 16, 2025
  6. Standard Webhooks specification, Standard Webhooks
  7. Introduction to concurrency control (MVCC), PostgreSQL 17 documentation
  8. How to use Hide My Email with Sign in with Apple, Apple Support, December 4, 2025
  9. Email checks API, FFraud
  10. disposable-email-domains, 224,174 domains, FFraud, October 26, 2026
  11. RFC 7505: A "Null MX" No Service Resource Record for Domains That Accept No Mail, IETF, June 1, 2015