Bloqueo de correos electrónicos de registro que no se pueden entregar con un gancho de Supabase Auth y DNS
Utilice un gancho de Supabase antes de la creación del usuario y búsquedas MX, A y AAAA para rechazar registros cuyo dominio de correo no pueda recibir correos, siguiendo las reglas establecidas en RFC 5321.
Un formulario de registro que verifica los correos electrónicos únicamente con una expresión regular aceptará cualquier dirección similar a un correo, incluidas aquellas en dominios que no pueden recibir correos en absoluto. Dado que Supabase crea el registro del usuario antes de que se haga clic en el correo de confirmación, cada una de esas direcciones deja una fila no verificada en su base de datos. Esta guía muestra cómo interceptar los registros con un gancho de Supabase Auth y utilizar búsquedas DNS para rechazar dominios que no cuentan con un servidor de correo funcional, qué establecen las reglas SMTP relevantes y dónde es necesario reforzar este enfoque antes de su uso en producción.
Dónde conectar el gancho al flujo de registro
Con Supabase, las solicitudes de registro suelen provenir directamente de la biblioteca del cliente, por lo que no existe una ruta del servidor propia donde se pueda validar la dirección primero. Supabase resuelve esto con Auth Hooks: puntos de conexión o funciones de base de datos a los que Supabase Auth llama en momentos específicos de su flujo. El gancho before-user-created se ejecuta antes de que se inserte un nuevo usuario, y puede permitir o rechazar la solicitud.
Los fragmentos a continuación provienen de un proyecto Next.js, pero la validación en sí se realiza en una función Edge de Supabase, por lo que no depende de tu framework front-end.
Habilitar el gancho en un proyecto local
Cuando Supabase se ejecuta localmente a través de la CLI, la interfaz de Studio podría no ofrecer una opción para los ganchos de autenticación. En ese caso, active el gancho en supabase/config.toml descomentando y completando esta sección:
#[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"
uri es el punto de extremo al que llamará Supabase, mientras que secrets contiene la clave de firma que Supabase utiliza para firmar cada solicitud de gancho. Observe el host: se usa host.docker.internal porque la pila local de Supabase funciona en Docker, y ese nombre permite que sus contenedores se conecten al servidor de funciones.
Creación de la función Edge
El punto de extremo aquí es una función Edge, una función del lado del servidor que Supabase despliega en un entorno de ejecución distribuido a nivel mundial y cercano a los usuarios. Cree una con la CLI:
npx supabase functions new before-user-created
La orden crea una nueva carpeta bajo supabase/functions, y la lógica se encuentra en su archivo index.ts. Según la documentación de Supabase para el hook before-user-created, Supabase envía un paquete con metadatos de la solicitud y el usuario que está a punto de ser creado:
{
"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
}
}
El campo importante para esta verificación es user.email.
Hasta dónde debe llegar la validación?
Una vez que se tiene la dirección, su dominio puede verificarse mediante DNS. Se podría ir más allá: resolver la dirección IP del servidor de correo, establecer una conexión TCP con él y probar la conversación SMTP. Pero generalmente no vale la pena. Muchos servidores rechazan o mienten ante tales pruebas, las verificaciones añaden una latencia considerable a cada registro, y la única prueba fiable de que existe un buzón de correo es cuando el usuario hace clic en el enlace de confirmación.
Una solución intermedia práctica es verificar que el dominio cuente con al menos un servidor de correo que se resuelva efectivamente a una dirección IP. Esto permite descartar fácilmente errores de escritura y dominios inventados. Aun así, quien esté decidido a contaminar su base de datos puede registrar dominios reales, por lo que considere esto como un filtro contra ruido y no como protección contra abusos. Hasta qué punto ir más allá depende de su modelo de amenazas.
Qué indican las reglas SMTP sobre los registros MX
Los dominios publican sus servidores de correo como registros MX (mail exchange), según lo definido para SMTP en RFC 5321. El módulo dns de Node ofrece resolveMx(), que devuelve el host exchange y la priority de cada registro, así como resolve4() y resolve6(), que devuelven las direcciones IPv4 e IPv6 de un host. Tres reglas determinan si un dominio puede recibir correo:
- No existir registros MX implica un MX implícito. La sección del RFC sobre cómo localizar el host destino establece que cuando la lista de MX está vacía, el propio dominio se considera el servidor de correo. Por lo tanto, son los registros A o AAAA del dominio los que determinan el resultado.
- Tener registros MX todos ellos inutilizables constituye un error. La misma sección exige que, si existen registros MX pero ninguno funciona, la entrega debe fallar. En la práctica: si ninguno de los servidores de correo indicados se resuelve a una dirección IP, se debe rechazar el registro.
La implementación del hook
La función a continuación aplica esas reglas. Lee el payload, extrae el dominio después de @ y devuelve 400 para entradas mal formadas. Luego busca los registros MX y maneja cada caso: si no hay registros, verifica las direcciones propias del dominio; si existe un único MX nulo, lo rechaza; de lo contrario, recorre los registros de intercambio y acepta la entrada en cuanto alguno tenga una dirección IP. Un objeto JSON vacío indica a Supabase que proceda. Una función auxiliar, hasIpAddress, consulta IPv4 e IPv6 en paralelo con Promise.allSettled, de modo que un fallo en una búsqueda no oculte un éxito en la otra.
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);
};
Problemas en este código que merecen corrección
La lógica sigue las especificaciones RFC, pero varios detalles se comportan de manera diferente a como lo interpreta el código:
resolveMx()lanza un error en lugar de devolver una lista vacía. En el caso de un dominio sin registros MX o de un dominio inexistente, Node rechaza la solicitud con errores comoENODATAoENOTFOUND. Eso significa que la rama relacionada con MX implícito rara vez se ejecuta; en su lugar, el bloquecatchgenera un error 500, y los dominios legítimos que dependen de MX implícito son rechazados como errores del servidor. Captura el errorENODATAalrededor deresolveMx()y recurre ahasIpAddress(domain).- La verificación de MX nulo podría no coincidir. Node indica la presencia de un MX nulo mediante una cadena
exchangevacía en lugar de “.”. La condiciónif (record.exchange)del bucle sigue rechazando dichos dominios, pero conviene comparar ambos valores para dejar clara la intención.
auth: "none" acepta a cualquier llamante. Verifique la solicitud firmada utilizando la clave configurada en config.toml, como describe la documentación de los ganchos de autenticación, para que personas ajenas no puedan llamar a la función.node:dns/promises funciona a través de su capa de compatibilidad con Node. Pruébelo en el entorno de despliegue que utilice.Permitir llamadas no autenticadas a la función
Finalmente, registre la función en config.toml y desactive la verificación JWT para ella. El nombre entre corchetes debe coincidir con el nombre de la carpeta de la función en supabase/functions:
[functions.before-user-created]
verify_jwt = false
La verificación JWT está desactivada porque aún no hay nadie conectado cuando se realiza un registro; es la propia verificación de firma del hook, y no un token de usuario, lo que debe proteger este endpoint.
Puntos clave
- Un hook de autenticación
before-user-createdle permite validar direcciones del lado del servidor incluso cuando el registro se activa desde la biblioteca del cliente. - Un dominio puede recibir correo si uno de sus hosts MX se resuelve, o, en ausencia de registros MX, si el propio dominio se resuelve; un MX nulo significa que no acepta ninguno.
- En Node,
resolveMx()lanza un error por la falta de registros, así que maneje explícitamente el errorENODATAen lugar de esperar un array vacío.