Blocking disposable emails in Supabase Auth
· 7 min read · Tutorials
To block disposable emails in Supabase Auth, register a Before User Created hook that points at an Edge Function. Supabase calls the function with the pending user before it's written to auth.users; the function checks user.email against an email-classification API and returns either {} to allow the signup or an error object with a 4xx http_code to reject it. The full function is below, including signature verification, a timeout that fits inside Supabase's hook budget, rate-limit backoff and a fail-open policy.
What the hook gives you
From the Before User Created hook docs:
- The payload has two keys.
metadataholdsuuid,time,name("before-user-created") andip_address.useris shaped like anauth.usersrow:id,email,phone,app_metadata.provider,user_metadata,identitiesand so on. - To allow the signup, return HTTP 200 with
{}(or 204 with no body). - To reject, return a 4xx status with
{ "error": { "http_code": 400, "message": "..." } }. The message is passed back to the client, sosupabase.auth.signUp()surfaces it as the error. - HTTP hooks are signed using the Standard Webhooks scheme. The secret looks like
v1,whsec_<base64>.
The general Auth Hooks page adds the constraint that shapes everything else: HTTP hooks should complete within 5 seconds (Postgres hooks get 2). Your function's total time, including the outbound API call, has to fit in that.
We're using an HTTP hook rather than a Postgres function because the check needs a synchronous outbound HTTP request. Postgres can do that only through extensions, and pg_net, the one most Supabase projects use, is asynchronous by design.
What to block, and what not to
The function calls pingutil's /v1/lookup with skip_enrichment: true. That returns the domain category, heuristic email_signals and a verdict in milliseconds, without running company enrichment.
- Block when
verdict.is_spamis true. That covers disposable domains from the community blocklist, reputation-flagged domains, and randomized local parts likeccjxx90sze@. - Allow
verdict.low_trustaddresses (info@,noreply@,sales@). Rejecting them costs you real customers who sign up with a shared inbox. Tag them later if you care. - Allow privacy relays such as Apple Hide My Email. They forward to a real, persistent inbox; relay services are not disposable email explains the difference.
If you're unsure whether blocking is right for your product at all, read block or flag disposable emails first. Flagging is the better default for some businesses.
Step 1: create the Edge Function
supabase functions new before-user-created
// supabase/functions/before-user-created/index.ts
import { Webhook } from 'https://esm.sh/standardwebhooks@1.0.0'
const HOOK_SECRET = (Deno.env.get('BEFORE_USER_CREATED_HOOK_SECRET') ?? '').replace('v1,whsec_', '')
const PINGUTIL_KEY = Deno.env.get('PINGUTIL_KEY') ?? ''
const FAIL_CLOSED = Deno.env.get('EMAIL_CHECK_FAIL_CLOSED') === 'true'
// Supabase gives HTTP hooks 5 seconds. Leave room for cold start and signature checks.
const LOOKUP_TIMEOUT_MS = 2000
// Per function instance. Good enough to stop hammering a rate-limited key.
let backoffUntil = 0
const json = (body: unknown, status = 200) =>
new Response(JSON.stringify(body), {
status,
headers: { 'Content-Type': 'application/json' },
})
const allow = () => json({})
const deny = (message: string, httpCode = 400) =>
json({ error: { http_code: httpCode, message } }, httpCode)
type Screen =
| { kind: 'checked'; verdict: { is_spam: boolean; low_trust: boolean; reason: string | null } }
| { kind: 'invalid' }
| { kind: 'unavailable'; why: string }
async function screenEmail(email: string): Promise<Screen> {
if (Date.now() < backoffUntil) return { kind: 'unavailable', why: 'rate_limited' }
let res: Response
try {
res = await fetch('https://pingutil.com/v1/lookup', {
method: 'POST',
headers: {
Authorization: `Bearer ${PINGUTIL_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ input: email, skip_enrichment: true }),
signal: AbortSignal.timeout(LOOKUP_TIMEOUT_MS),
})
} catch (err) {
const why = err instanceof Error && err.name === 'TimeoutError' ? 'timeout' : 'network'
return { kind: 'unavailable', why }
}
if (res.status === 429) {
const body = await res.json().catch(() => ({}))
backoffUntil = Date.now() + (Number(body.retry_after_seconds) || 60) * 1000
return { kind: 'unavailable', why: 'rate_limited' }
}
if (res.status === 400) return { kind: 'invalid' }
if (!res.ok) return { kind: 'unavailable', why: `http_${res.status}` }
const data = await res.json()
return { kind: 'checked', verdict: data.verdict }
}
Deno.serve(async (req) => {
const payload = await req.text()
const headers = Object.fromEntries(req.headers)
let user: { email?: string | null }
try {
const event = new Webhook(HOOK_SECRET).verify(payload, headers) as {
user: { email?: string | null }
}
user = event.user
} catch {
// Not from Supabase Auth, or the secret is wrong. Never act on an unsigned payload.
return json({ error: { message: 'Invalid signature' } }, 401)
}
const email = (user.email ?? '').trim().toLowerCase()
// Phone and anonymous signups have no email: nothing to check.
if (!email) return allow()
const screen = await screenEmail(email)
if (screen.kind === 'invalid') {
return deny('Please enter a valid email address.')
}
if (screen.kind === 'checked') {
if (screen.verdict.is_spam) {
return deny(
screen.verdict.reason === 'disposable_domain'
? 'Temporary email addresses are not allowed. Please use a permanent address.'
: 'This email address cannot be used to sign up.',
)
}
return allow()
}
// The check could not run.
console.warn(JSON.stringify({ msg: 'email check unavailable', why: screen.why, email }))
if (FAIL_CLOSED) {
// Hooks must reject with a 4xx status; 429 tells the client to retry later.
return deny('Sign-ups are briefly unavailable. Please try again in a minute.', 429)
}
return allow()
})
The structure follows Supabase's own example: read the raw body with req.text() (signature verification needs the exact bytes), strip the v1,whsec_ prefix, and verify before trusting anything in the payload.
Every code path returns an explicit response. Don't rely on how Auth treats a function that throws or runs past the time limit; decide the outcome yourself.
Step 2: set secrets and deploy
supabase secrets set PINGUTIL_KEY=pk_live_...
supabase functions deploy before-user-created --no-verify-jwt
--no-verify-jwt matters. Supabase Auth authenticates hook calls with the webhook signature, not a user JWT, so the function's default JWT check would reject them. The Standard Webhooks verification in the code replaces it. Supabase's docs for the other HTTP auth hooks deploy the same way.
Step 3: register the hook
- In the dashboard, open Authentication > Hooks and add a Before User Created hook.
- Choose the HTTPS hook type and enter your function URL:
https://<project-ref>.supabase.co/functions/v1/before-user-created. - Generate a secret. Copy the full
v1,whsec_...value. - Store it:
supabase secrets set BEFORE_USER_CREATED_HOOK_SECRET='v1,whsec_...'. Quote it, because of the comma.
Test with a throwaway domain from your app:
const { error } = await supabase.auth.signUp({
email: 'test@mailinator.com',
password: 'a long enough password',
})
console.log(error?.message)
// "Temporary email addresses are not allowed. Please use a permanent address."
Then sign up with a normal address to confirm the allow path, and check the function logs for unexpected email check unavailable lines.
The timeout and failure choices
Why 2 seconds. The hook has 5 seconds end to end. A cold Edge Function start, the signature check and Supabase's own round trip all come out of that budget. In skip_enrichment mode the lookup normally responds in a small fraction of 2 seconds, so the timeout only triggers when something is actually wrong.
Why 429 doesn't retry. pingutil allows 60 requests per minute per key, and a 429 body says "retry_after_seconds": 60. Waiting that long would blow the hook's 5-second limit many times over. The function records backoffUntil, skips the API until then, and goes straight to the fallback. That respects the server's instruction and keeps signups fast.
Why fail open by default. If the check can't run, the function allows the signup and logs the email with the reason. The alternative, EMAIL_CHECK_FAIL_CLOSED=true, rejects with a 429 and a try-again message, so every signup fails until the API recovers.
| Situation | Fail open | Fail closed |
|---|---|---|
| Brief API outage | Real users sign up, a few disposable addresses may slip in | Every signup fails during the outage |
| Rate limit hit | Signups continue unchecked for up to a minute | Signups fail for up to a minute |
| Good fit | Free tiers, trials, most SaaS | Signups that grant credits or money immediately |
For most apps, fail open plus cleanup is the better trade. The hook can't tag the user it's about to create, so cleanup works from the logs: re-check the logged emails after the window passes, or re-check users created during the outage with a scheduled job that queries auth.users by created_at. Gate anything expensive (sending invites, granting credits) on email confirmation, and the exposure during an outage is small.
Limits worth knowing
- The disposable list is a community blocklist, re-synced weekly. A domain registered yesterday won't be on it; the randomized-local-part signal catches some of those, not all.
- The randomness heuristic spares real names with digits (
priya99@), but non-name handles with random digits can be flagged. Watch rejected-signup logs for the first week. - One key gives you 60 lookups per minute. If your signup rate is above that, the backoff path will run often, and you should rethink where the check sits.
The same pattern in Auth0 is in checking emails in an Auth0 pre-user-registration Action. For the raw request and response, see the API quickstart or the API reference.
Questions people ask
- Which Supabase plans support the Before User Created hook?
- Supabase's Auth Hooks documentation lists Before User Created as available on the Free and Pro plans.
- Can I block disposable emails in Supabase with a database trigger instead?
- A trigger on auth.users fires after Auth has decided to create the user, and raising an error there surfaces as a generic database error. The Before User Created hook exists for this case: it runs before the user row is written and lets you return a clear rejection message.
- Does the Before User Created hook run for OAuth signups?
- Supabase's own examples for this hook include rejecting signups from an OAuth provider, so OAuth signups pass through it. The email in the payload is whatever the provider returned, so handle a missing email.