This article is published in English.
Blocking Undeliverable Sign-Up Emails with a Supabase Auth Hook and DNS
Use a Supabase before-user-created hook and MX, A and AAAA lookups to reject sign-ups whose email domain cannot receive mail, following the rules in RFC 5321.
A sign-up form that checks emails only with a regular expression will accept anything shaped like an address, including addresses on domains that cannot receive mail at all. Because Supabase creates the user record before the confirmation email is clicked, every such address leaves an unverified row in your database. This guide shows how to intercept sign-ups with a Supabase Auth hook and use DNS lookups to reject domains that have no working mail server, what the relevant SMTP rules say, and where the approach needs hardening before production.
Where to hook into the sign-up flow
With Supabase, sign-up requests typically come straight from the client library, so there is no server route of your own where you could validate the address first. Supabase solves this with Auth Hooks: endpoints or database functions that Supabase Auth calls at specific points in its flow. The before-user-created hook runs before a new user is inserted, and it can allow or reject the request.
The snippets below come from a Next.js project, but the validation itself runs in a Supabase Edge Function, so it does not depend on your front-end framework.
Enabling the hook in a local project
When Supabase runs locally through the CLI, the Studio interface may not offer a setting for Auth hooks. In that case, enable the hook in supabase/config.toml by uncommenting and filling in this section:
#[auth.hook.before_user_created]
#enabled = true
#secrets="v1,whsec_ some secret" (needs to be added)
#uri = "http://host.docker.internal:54321/functions/v1/before-user-created"
The uri is the endpoint Supabase will call, and secrets holds the signing secret Supabase uses to sign each hook request. Note the host: host.docker.internal is used because the local Supabase stack runs in Docker, and that name lets its containers reach the functions server.
Creating the Edge Function
The endpoint here is an Edge Function, a server-side function that Supabase deploys to a globally distributed runtime close to users. Create one with the CLI:
npx supabase functions new before-user-created
The command scaffolds a new folder under supabase/functions, and the logic goes into its index.ts. According to the Supabase documentation for the before-user-created hook, Supabase sends a payload with request metadata and the user that is about to be created:
{
"metadata": {
"uuid": "8b34dcdd-9df1–4c10–850a-b3277c653040",
"time": "2025–04–29T13:13:24.755552–07:00",
"name": "before-user-created",
"ip_address": "127.0.0.1"
},
"user": {
"id": "ff7fc9ae-3b1b-4642–9241–64adb9848a03",
"aud": "authenticated",
"role": "",
"email": "valid.email@supabase.com",
"phone": "",
"app_metadata": {
"provider": "email",
"providers": ["email"]
},
"user_metadata": {},
"identities": [],
"created_at": "0001–01–01T00:00:00Z",
"updated_at": "0001–01–01T00:00:00Z",
"is_anonymous": false
}
}
The field that matters for this check is user.email.
How far should validation go?
Once you have the address, its domain can be checked with DNS. You could go further: resolve the mail server's IP address, open a TCP connection to it and probe the SMTP conversation. That is usually not worth it. Many servers refuse or lie to such probes, the checks add noticeable latency to every sign-up, and the only reliable proof that a mailbox exists is the user clicking the confirmation link.
A pragmatic middle ground is to verify that the domain has at least one mail server that actually resolves to an IP address. This filters out typos and invented domains cheaply. Someone determined to pollute your database can still register real domains, so treat this as a filter against noise, not as abuse protection. How far to go beyond it depends on your threat model.
What the SMTP rules say about MX records
Domains publish their mail servers as MX (mail exchange) records, as defined for SMTP in RFC 5321. Node's dns module exposes resolveMx(), which returns each record's exchange host and priority, and resolve4() and resolve6(), which return a host's IPv4 and IPv6 addresses. Three rules determine whether a domain can receive mail:
- No MX records means an implicit MX. The RFC's section on locating the target host states that when the MX list is empty, the domain itself is treated as the mail server. So the domain's own A or AAAA records decide the outcome.
- MX records that are all unusable are an error. The same section requires that if MX records exist but none of them works, delivery must fail. In practice: if no listed exchange resolves to an IP address, reject the sign-up.
- A null MX means the domain accepts no mail. RFC 7505 (section 3) defines a single MX record with preference 0 and an empty exchange, written as "." in zone files (the record format is described in Section 3.3.9 of RFC 1035), as an explicit statement that the domain does not accept email.
The hook implementation
The function below applies those rules. It reads the payload, extracts the domain after the @, and returns 400 for malformed input. It then looks up MX records and handles each case: with no records, it checks the domain's own addresses; with a single null MX, it rejects; otherwise it walks the exchanges and accepts as soon as one has an IP address. An empty JSON object signals Supabase to proceed. A helper, hasIpAddress, queries IPv4 and IPv6 in parallel with Promise.allSettled, so a failure in one lookup does not hide a success in the other.
import "@supabase/functions-js/edge-runtime.d.ts";
import { withSupabase } from "@supabase/server";
import dns from "node:dns/promises";
export default {
fetch: withSupabase({ auth: "none" }, async (req) => {
// Called by another service with a secret key
// ctx.supabaseAdmin bypasses RLS — use for privileged operations
try {
const r = await req.json();
const email = r.user.email;
//seems like strict email validation is not required,initial thoughts,
// needs to be investigated
if (typeof email !== "string") {
return Response.json({
error: {
message: "",
},
}, {
status: 400,
});
}
const domain = (email as string).split("@")[1];
if (!domain) {
return Response.json({
error: {
message: "bad request",
},
}, {
status: 400,
});
}
const mx_records = await dns.resolveMx(domain);
if (mx_records.length === 0) { //case 1: no mx record present
// email server may be domain itself
const mail_server = domain;
const has_ip_address = await hasIpAddress(mail_server);
if (has_ip_address) {
return Response.json({});
} else {
return Response.json({
error: {
message: "bad request",
},
}, {
status: 400,
});
}
} else {
if (mx_records.length === 1) { // case 3: domain does not accept mails
const record = mx_records[0];
if (record.priority === 0 && record.exchange === ".") {
return Response.json({
error: {
message: "bad request",
},
}, {
status: 400,
});
}
}
for (const record of mx_records) { // inspection for case 2
if (record.exchange) {
const has_ip_address = await hasIpAddress(record.exchange);
if (has_ip_address) {
return Response.json({});
}
}
}
return Response.json({
error: {
message: "bad request",
},
}, {
status: 400,
});
}
} catch (_e) {
return Response.json({
error: {
message: "internal server error",
},
}, {
status: 500,
});
}
}),
};
const hasIpAddress = async (mail_server: string): Promise<boolean> => {
const [ipv4, ipv6] = await Promise.allSettled([
dns.resolve4(mail_server),
dns.resolve6(mail_server),
]);
return (ipv4.status === "fulfilled" && ipv4.value.length > 0) ||
(ipv6.status === "fulfilled" && ipv6.value.length > 0);
};
Pitfalls in this code worth fixing
The logic follows the RFCs, but several details behave differently from how the code reads:
resolveMx()throws rather than returning an empty list. For a domain with no MX records or a nonexistent domain, Node rejects with errors likeENODATAorENOTFOUND. That means the implicit-MX branch rarely runs; instead thecatchreturns a 500, and legitimate domains that rely on an implicit MX are rejected as server errors. CatchENODATAaroundresolveMx()and fall back tohasIpAddress(domain).- The null MX check may not match. Node reports a null MX with an empty
exchangestring rather than ".". The loop'sif (record.exchange)guard still rejects such domains, but compare against both values to make the intent explicit. - The hook's signature is never verified. The inline comment mentions a secret, but
auth: "none"accepts any caller. Verify the signed request using the secret configured inconfig.toml, as the Auth hooks documentation describes, so outsiders cannot call the function. - Check the rejection format. Supabase expects errors in a specific shape (including an HTTP status code in the error object); confirm the current format in the hook docs so users see a meaningful message.
- Add timeouts. Slow DNS servers directly delay sign-up, so bound each lookup.
- Runtime: Edge Functions run on Deno, and
node:dns/promisesworks through its Node compatibility layer. Test it in your deployment target.
Allowing unauthenticated calls to the function
Finally, register the function in config.toml and turn off JWT verification for it. The name in brackets must match the function's folder name in supabase/functions:
[functions.before-user-created]
verify_jwt = false
JWT verification is disabled because nobody is signed in yet when a sign-up happens; the hook's own signature check, not a user token, is what should protect this endpoint.
Key takeaways
- A
before-user-createdAuth hook lets you validate addresses server-side even when sign-up is triggered from the client library. - A domain can receive mail if one of its MX hosts resolves, or, with no MX records, if the domain itself resolves; a null MX means it accepts none.
- In Node,
resolveMx()throws for missing records, so handleENODATAexplicitly instead of expecting an empty array. - DNS checks catch typos and fake domains, not real but unused mailboxes; email confirmation remains the only proof of ownership.
- Verify the hook's signature and bound lookup times before relying on this in production.