Bloquer les e-mails d’inscription non livrables à l’aide d’un hook Supabase Auth et de DNS
Utilisez un hook Supabase pré-défini avant la création de compte ainsi que des recherches MX, A et AAAA pour rejeter les inscriptions dont le domaine e-mail ne peut pas recevoir de courriels, conformément aux règles de RFC 5321.
Un formulaire de inscription qui ne vérifie les adresses e-mail qu’avec une expression régulière acceptera tout ce qui ressemble à une adresse, y compris des adresses sur des domaines incapables de recevoir du courrier. Comme Supabase crée le enregistrement utilisateur avant même que l’e-mail de confirmation ne soit cliqué, chaque telle adresse laisse une ligne non vérifiée dans votre base de données. Ce guide montre comment intercepter les inscriptions à l’aide d’un hook Supabase Auth et utiliser des recherches DNS pour rejeter les domaines ne disposant pas de serveur mail fonctionnel, ce que prévoient les règles SMTP pertinentes, ainsi que les points où cette approche doit être renforcée avant mise en production.
Où intégrer le hook dans le flux d’inscription
Avec Supabase, les demandes d’inscription proviennent généralement directement de la bibliothèque client, il n’y a donc pas de route serveur propre que vous pourriez utiliser pour valider l’adresse en premier. Supabase résout ce problème grâce aux Auth Hooks : des points d’entrée ou des fonctions de base de données que Supabase Auth appelle à des moments précis de son flux. Le hook before-user-created s’exécute avant l’insertion d’un nouveau utilisateur, et il peut autoriser ou rejeter la demande.
Les extraits ci-dessous proviennent d’un projet Next.js, mais la validation elle-même s’effectue dans une fonction Edge de Supabase, ce qui signifie qu’elle ne dépend pas de votre framework front-end.
Activation du hook dans un projet local
Lorsque Supabase est exécuté localement via la CLI, l’interface Studio ne propose pas nécessairement de paramètre pour les hooks d’authentification. Dans ce cas, activez le hook dans supabase/config.toml en décommentant et en remplissant cette 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"
uri correspond à l’endpoint que Supabase appellera, tandis que secrets contient le secret de signature utilisé par Supabase pour signer chaque demande de hook. Notez que host.docker.internal est utilisé car l’ensemble de Supabase local fonctionne dans Docker, et ce nom permet à ses conteneurs d’accéder au serveur de fonctions.
Création de la fonction Edge
L’endpoint ici représente une fonction Edge, c’est-à-dire une fonction côté serveur que Supabase déploie sur un environnement de exécution distribué à l’échelle mondiale, proche des utilisateurs. Créez-en une avec la CLI :
npx supabase functions new before-user-created
La commande crée un nouveau dossier sous supabase/functions, et la logique est placée dans son fichier index.ts. Selon la documentation de Supabase concernant le hook avant création d’utilisateur, Supabase envoie un en-tête contenant des métadonnées de la requête ainsi que l’utilisateur qui est sur le point d’être créé :
{
"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
}
}
Le champ important pour cette vérification est user.email.
Jusqu’où doit aller la validation ?
Une fois l’adresse obtenue, son domaine peut être vérifié via DNS. On pourrait aller plus loin : résoudre l’adresse IP du serveur de messagerie, établir une connexion TCP avec lui et examiner la conversation SMTP. Cela n’en vaut généralement pas la peine. De nombreux serveurs refusent ou mentent face à de telles tentatives, ces vérifications ajoutent une latence significative à chaque inscription, et la seule preuve fiable de l’existence d’une boîte aux lettres est que l’utilisateur clique sur le lien de confirmation.
Une solution intermédiaire pragmatique consiste à vérifier que le domaine dispose d’au moins un serveur mail qui se résout réellement en une adresse IP. Cela permet d’éliminer facilement les fautes de frappe et les domaines inventés. Une personne déterminée à polluer votre base de données peut toujours enregistrer des domaines réels, il convient donc de considérer cela comme un filtre contre le bruit et non comme une protection contre les abus. La mesure à prendre dépend de votre modèle de menaces.
Ce que disent les règles SMTP au sujet des enregistrements MX
Les domaines publient leurs serveurs mail sous forme d’enregistrements MX (mail exchange), tels que définis pour SMTP dans RFC 5321. Le module dns de Node expose resolveMx(), qui renvoie l’hôte exchange et la priority de chaque enregistrement, ainsi que resolve4() et resolve6(), qui renvoient les adresses IPv4 et IPv6 d’un hôte. Trois règles déterminent si un domaine peut recevoir du courrier :
- Absence de records MX signifie un MX implicite. La section du RFC concernant la localisation de l’hôte cible indique que lorsque la liste MX est vide, le domaine lui-même est considéré comme le serveur de messagerie. Ainsi, ce sont les records A ou AAAA du domaine qui déterminent le résultat.
- Des records MX tous inutilisables constituent une erreur. La même section exige que, s’il existe des records MX mais qu’aucun ne fonctionne, la livraison échoue. En pratique : si aucun des serveurs de messagerie listés ne correspond à une adresse IP, rejeter l’inscription.
Mise en œuvre du hook
La fonction ci-dessous applique ces règles. Elle lit la charge utile, extrait le domaine après @, et renvoie 400 en cas d’entrée mal formatée. Ensuite, elle recherche les enregistrements MX et gère chaque cas : en l’absence d’enregistrements, elle vérifie les adresses propres au domaine ; en cas d’un seul enregistrement MX nul, elle rejette la demande ; sinon, elle parcourt les échanges et accepte dès qu’un d’eux possède une adresse IP. Un objet JSON vide indique à Supabase de procéder. Une fonction d’aide, hasIpAddress, interroge simultanément les adresses IPv4 et IPv6 à l’aide de Promise.allSettled, afin qu’une échec dans une recherche ne masque pas un succès dans l’autre.
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);
};
Péripéties dans ce code à corriger
La logique suit les RFC, mais plusieurs détails se comportent différemment de ce que lit le code :
resolveMx()lance une exception au lieu de retourner une liste vide. Pour un domaine sans enregistrements MX ou un domaine inexistant, Node rejette la requête avec des erreurs telles queENODATAouENOTFOUND. Cela signifie que la branche implicite MX est rarement exécutée ; au lieu de cela, le bloccatchgénère une erreur 500, et les domaines légitimes qui dépendent d’un MX implicite sont rejetés en tant qu’erreurs serveur. Capturez l’erreurENODATAautour deresolveMx()et passez àhasIpAddress(domain)en cas de problème.- Vérification du MX nul : résultat imprévisible. Node indique un MX nul avec une chaîne
exchangevide plutôt que ".". La conditionif (record.exchange)du boucle rejette néanmoins de tels domaines, mais comparez les deux valeurs pour clarifier l’intention.
auth: "none" accepte n’importe quel appelant. Vérifiez la demande signée en utilisant le secret configuré dans config.toml, comme l’indique la documentation des hooks Auth, afin que des tiers ne puissent pas appeler la fonction.node:dns/promises fonctionne via sa couche de compatibilité Node. Testez-le dans votre environnement de déploiement.Permettre des appels non authentifiés à la fonction
Finalement, enregistrez la fonction dans config.toml et désactivez sa vérification JWT. Le nom entre crochets doit correspondre au nom du dossier de la fonction dans supabase/functions:
[functions.before-user-created]
verify_jwt = false
La vérification JWT est désactivée car personne n’est encore connecté au moment de l’inscription ; c’est la vérification de signature propre à l’hook, et non un jeton d’utilisateur, qui doit protéger cet endpoint.
Points clés
- Un hook Auth
before-user-createdvous permet de valider les adresses du côté serveur, même lorsque l’inscription est déclenchée depuis la bibliothèque client. - Un domaine peut recevoir du courrier si l’un de ses hôtes MX se résout, ou, en l’absence d’enregistrements MX, si le domaine lui-même se résout ; une valeur MX nulle signifie qu’il n’en accepte aucun.
- Au Node,
resolveMx()lance une erreur en cas d’absence d’enregistrements, il faut donc gérer explicitement l’erreurENODATAau lieu d’attendre un tableau vide.