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:
| Question | Edge Function | Postgres function |
|---|---|---|
| What decides | FFraud's free API, by domain | FFraud's MIT list in a table |
| Time the check takes | A median of 197 ms in our test, one API call | 11 to 15 microseconds in our test |
| What leaves your project | The domain, sent as user@ and the domain | Nothing |
| Relay addresses such as Hide My Email | Accepted, the API answers alias | Refused, except through Sign in with Apple |
| When the check breaks | The signup goes on | Signups fail with a 500 until you fix it |
| Upkeep | None | A 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 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_RELAYSto 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:
# .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?
-- 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.commatchesmailinator.com, through the primary key's index. - Sign in with Apple passes. Apple gives each app a
privaterelay.appleid.comaddress when a person picks Hide My Email (Apple Support). The list holds that domain, so the function skips signups whose provider isapple. Other relays typed into an email signup, such asduck.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.anonandauthenticatedget 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:
#!/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;
SQLThe 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:
- Postgres function, listed domain0.01 ms
- Postgres function, unlisted subdomain0.02 ms
- Edge Function, fastest of 12124 ms
- Edge Function, median of 12197 ms
- 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:
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 msThe 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 (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: 1Supabase 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
#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
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
Sources
Before User Created Hook Auth Hooks internal/hooks/hookshttp/hookshttp.go, v2.197.0 internal/hooks/v0hooks/manager.go, v2.197.0 Issue 2235: before_user_created Auth Hook Returns "Invalid payload sent to hook" Standard Webhooks specification Introduction to concurrency control (MVCC) How to use Hide My Email with Sign in with Apple Email checks API disposable-email-domains, 224,174 domains RFC 7505: A "Null MX" No Service Resource Record for Domains That Accept No Mail