Block disposable emails and fake signups in Supabase Auth
October 29, 2026 · 4 min read
Supabase Auth makes signup easy, which is exactly why fake signups show up. If your app grants free credits on signup, every throwaway inbox is a cost. Supabase gives you a clean place to stop them: the before-user-created auth hook, which runs before a user row is created and can reject the signup. It's available on the Free and Pro plans. (Supabase docs)
This guide shows two ways to use it: a do-it-yourself Postgres function, and an HTTP hook that calls BurnGuard.
How the hook works
When someone signs up, Supabase calls your hook with a payload that includes the user being created (with email) and metadata about the request. Your hook returns either an empty object (allow) or an error object (reject):
{ "error": { "http_code": 403, "message": "We couldn't create this account." } }
Hooks can be Postgres functions or HTTPS endpoints. Postgres hooks are fast and need no extra infrastructure. HTTPS hooks can call outside services.
Option 1: DIY in Postgres
Create a table of disposable domains, load it from the open-source disposable-email-domains list (CC0), and reject matches. Add Gmail normalization so j.o.h.n+7@gmail.com can't create a second account.
create table if not exists public.disposable_domains (domain text primary key);
-- load ~10k rows from disposable_email_blocklist.conf with \copy or a seed script
create or replace function public.normalize_email(e text)
returns text language sql immutable as $$
select case
when split_part(lower(e), '@', 2) in ('gmail.com', 'googlemail.com')
then replace(split_part(split_part(lower(e), '@', 1), '+', 1), '.', '') || '@gmail.com'
else split_part(split_part(lower(e), '@', 1), '+', 1) || '@' || split_part(lower(e), '@', 2)
end
$$;
create or replace function public.hook_before_user_created(event jsonb)
returns jsonb language plpgsql as $$
declare
v_email text := event->'user'->>'email';
v_domain text := lower(split_part(v_email, '@', 2));
begin
if v_email is null then
return '{}'::jsonb; -- phone or OAuth without email
end if;
if exists (select 1 from public.disposable_domains d where d.domain = v_domain) then
return jsonb_build_object('error', jsonb_build_object(
'http_code', 403, 'message', 'Please use a permanent email address.'));
end if;
if exists (select 1 from auth.users u where public.normalize_email(u.email) = public.normalize_email(v_email)) then
return jsonb_build_object('error', jsonb_build_object(
'http_code', 409, 'message', 'An account with this email already exists.'));
end if;
return '{}'::jsonb;
end $$;
grant execute on function public.hook_before_user_created to supabase_auth_admin;
grant select on public.disposable_domains to supabase_auth_admin;
grant usage on schema public to supabase_auth_admin;
Then enable it under Authentication → Hooks → Before User Created and select the function.
Two notes before you ship this:
- The duplicate check scans
auth.userswith a function call. Fine for thousands of users; for more, store the normalized email in your ownprofilestable with an index and check there. - Keep the domain list fresh. New throwaway domains appear constantly; schedule a refresh.
There's also a community Postgres extension, email_guard, that packages a disposable-domain check for Supabase.
What the DIY version misses
Disposable domains and Gmail aliases are the cheapest tricks, so blocking them pays off immediately. But farmers who meet a block move on to:
- Real Gmail accounts created in bulk, each different after normalization.
- Fresh IPs from cloud servers and VPNs.
- The same browser behind every account.
Catching those takes IP intelligence, velocity windows across signups, and a device signal from the browser, which a Postgres hook can't see on its own.
Option 2: An HTTP hook that calls BurnGuard
An HTTPS hook (for example a Supabase Edge Function) can call BurnGuard's /v1/check, which adds datacenter/VPN/Tor detection, velocity per IP and subnet, and clustering on top of email checks. The full, copy-paste function is in our Supabase recipe. The core of it:
const res = await fetch(`${Deno.env.get("BURNGUARD_URL")}/v1/check`, {
method: "POST",
headers: {
Authorization: `Bearer ${Deno.env.get("BURNGUARD_SECRET_KEY")}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ event: "signup", email, ip: event.metadata?.ip_address }),
signal: AbortSignal.timeout(1500), // fail open
});
const { decision } = res.ok ? await res.json() : { decision: "allow" };
if (decision === "block") {
return Response.json({ error: { http_code: 403, message: "We couldn't create this account." } });
}
return Response.json({});
Verify the hook signature (Supabase signs HTTP hooks using the Standard Webhooks format) and always fail open: a timeout should never stop a real user from signing up.
Gate credits instead of blocking
A hook can only say yes or no. But the best answer to a borderline signup is usually "yes, with fewer free credits". BurnGuard returns limit with a recommended_credit_fraction. Store that per email in a table the hook writes to, and read it when you create the user's credit balance. Real users get the full tier after verifying; farmers don't get the payoff. See credit gating.
Device reuse needs your signup page
The device token comes from the browser, so it isn't in the hook payload. If you want device-reuse detection, which is the strongest multi-account signal, call /v1/check from your own signup endpoint with the bg_device token from the snippet, then call supabase.auth.signUp only if the decision allows it.
Checklist
- Enable a before-user-created hook.
- Block disposable domains and normalized-email duplicates (DIY or BurnGuard).
- Add IP and velocity signals for bursts from cloud IPs.
- Gate free credits for borderline signups instead of blocking them.
- Start in monitor mode, then enforce once the results look right.