Block disposable emails in Supabase Auth
Call detect from a before-user-created hook before the Auth user exists. Fail open on timeouts. Do not use after-user-created as the only gate.
Block disposable emails in Supabase Auth by classifying the address in a before user created hook, not after the user row exists. Call GET /api/v1/emails/detect from the hook URL. Return HTTP 200 or 202 to allow the signup. Return an error object with http_code and message to reject. Fail open on timeout, 5xx, or rate-limit 429. Do not treat relay_domain as disposable.
This assumes a key with email:detect. See Creating API keys and your first detect check. Auth.js / NextAuth stays on callbacks.signIn in the Better Auth and Clerk recipe. This page is Supabase-only.
after-user-created (and database webhooks on auth.users) fire when the user already exists. You can delete the row. You cannot stop the confirmation email, and you cannot stop a session if you also allow instant sign-in. Supabase documents before-user-created as the pre-insert gate. Use it.
The hook is HTTPS-only. Local http:// endpoints are rejected. Auth 2.187.0 and later accept 200 or 202 as success. Older versions required 204. If you still run an older Auth image, upgrade before you copy the snippets below.
Supabase POSTs a JSON payload. The email lives on user.email for email/password and magic-link signups. OAuth identities may send the address on user.email after the provider returns it. Read the field that exists. Do not invent a second source of truth from the client.
Send x-supabase-client-ip or the documented forwarding header to detect if you need IP-based abuse notes. The address is still the decision.
Allow: HTTP 200 or 202 with an empty body, or no error object.
Deny: JSON in this shape (Auth 2.187.0+):
{
"error": {
"http_code": 400,
"message": "Use a lasting email address."
}
}http_code must be in 400–500. message is what the client can show. Do not put RATE_LIMIT_EXCEEDED there. Do not say "invalid API key."
Keep the EmailGuard secret in the hook host environment. Do not put it in NEXT_PUBLIC_* or a browser Edge Function that ships to the client.
const DETECT = "https://emailguard.co/api/v1/emails/detect";
type DetectData = { disposable?: boolean };
export async function classifyEmail(email: string, apiKey: string): Promise<
| { ok: true; disposable: boolean }
| { ok: false; reason: "timeout" | "rate" | "auth" | "http" }
> {
const ctrl = new AbortController();
const t = setTimeout(() => ctrl.abort(), 2000);
try {
const res = await fetch(`${DETECT}?email=${encodeURIComponent(email)}`, {
headers: { Authorization: `Bearer ${apiKey}` },
signal: ctrl.signal,
});
if (res.status === 401 || res.status === 403) return { ok: false, reason: "auth" };
if (res.status === 429) return { ok: false, reason: "rate" };
if (!res.ok) return { ok: false, reason: "http" };
const body = (await res.json()) as { data?: DetectData };
return { ok: true, disposable: Boolean(body.data?.disposable) };
} catch {
return { ok: false, reason: "timeout" };
} finally {
clearTimeout(t);
}
}Hook handler sketch:
export async function beforeUserCreated(req: Request): Promise<Response> {
const payload = (await req.json()) as { user?: { email?: string } };
const email = payload.user?.email?.trim();
if (!email) {
return Response.json(
{ error: { http_code: 400, message: "Email is required." } },
{ status: 200 },
);
}
const result = await classifyEmail(email, process.env.EMAILGUARD_API_KEY ?? "");
if (!result.ok) {
// fail open: auth/rate/timeout/5xx must not look like a bad address
console.error("emailguard_detect_skipped", result.reason);
return new Response(null, { status: 200 });
}
if (result.disposable) {
return Response.json(
{
error: {
http_code: 400,
message: "Use a lasting email address.",
},
},
{ status: 200 },
);
}
return new Response(null, { status: 200 });
}Supabase still wants HTTP 200 on the hook transport when you attach an error object. The Auth API maps error.http_code to the client. Check the live hook docs if you pin an Auth version older than 2.187.0.
A Supabase Edge Function can serve the hook URL if it stays server-side and the secret lives in Function secrets. A Cloudflare Worker or your API is the same pattern. The hook must finish before Auth's timeout. Two seconds for detect is the same budget as fail open.
Do not call detect from a Postgres trigger. You will hold a transaction on an HTTP hop.
Register the hook in the Supabase dashboard under Auth hooks. Point it at the public HTTPS URL. Confirm the Auth version in Project Settings is 2.187.0 or later before you rely on 200/202 plus the error object. If you self-host Auth, pin that version in the image tag. A mismatch here looks like “the hook never blocks” or “every signup 500s.”
Test with a known disposable domain from your first detect check and with a Gmail address. Then kill the EmailGuard secret and confirm signup still succeeds. That is the fail-open test. If it fails closed, you mapped 401 onto the disposable message.
| Flag | In this hook |
|---|---|
disposable | Reject |
relay_domain | Allow unless you have a written policy |
role_address | Product call. Do not copy disposable copy |
public_domain | Work-email forms only |
| Timeout / 429 / 5xx | Allow. Log. Recheck later |
Status codes: handle email validation API errors.
Store email_check=ok or skipped on your profiles row after the hook. The hook cannot write your app tables. A small after-user-created function can persist the last detect result for support. Do not make that function the only block.
Rate limits on detect are per API key, not per Supabase project. One key shared with a Next.js Route Handler and this hook will share the one-minute window. Use a dedicated key for Auth if signup volume is bursty.
Using after-user-created as the only gate. The user exists.
Returning HTTP 400 from the hook without the error object. Auth may treat that as a hook outage, not a user-facing reject, depending on version.
Failing closed on 401. The key is wrong. Allow the user or you will lock the form for everyone.
Blocking privaterelay.appleid.com by string. That is a privacy relay, not disposable.
SMTP from the hook. Do not probe MX from Deno. Classification is enough. Confirmation email still proves the inbox.
Calling detect twice. Once in the hook and once in a Next.js middleware on the same submit. You will 429 yourself. Pick one gate. The hook is the one that runs before auth.users exists.
Not as a first-class Auth flag equivalent to Clerk's dashboard toggle. You add a before-user-created hook.
You can delete after insert. You cannot prevent the insert from a trigger that must call HTTP without extra machinery. Use the hook.
200 or 202 from the hook (Auth 2.187.0+). Older Auth wanted 204.
No. The EmailGuard key is a secret.
The hook still runs before the user is created. Classify user.email before you send the link.