Accueil / Articles / Bloquer les e-mails d’inscription non livrables à l’aide d’un hook Supabase Auth et de DNS

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.

1680 mots

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 :

  1. 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.
  2. 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.
  • Un MX nul signifie que le domaine ne reçoit pas de courriels. Le RFC 7505 (section 3) définit un enregistrement MX unique avec une préférence de 0 et un échange vide, noté "." dans les fichiers de zone (le format de l’enregistrement est décrit à la section 3.3.9 du RFC 1035), afin d’indiquer explicitement que le domaine ne reçoit pas de courriels.
  • 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 que ENODATA ou ENOTFOUND. Cela signifie que la branche implicite MX est rarement exécutée ; au lieu de cela, le bloc catch gé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’erreur ENODATA autour de resolveMx() 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 exchange vide plutôt que ".". La condition if (record.exchange) du boucle rejette néanmoins de tels domaines, mais comparez les deux valeurs pour clarifier l’intention.
  • L’empreinte numérique du hook n’est jamais vérifiée. Le commentaire intégré mentionne un secret, mais 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.
  • Vérifiez le format de rejet. Supabase attend des erreurs sous une forme spécifique (y compris un code d’état HTTP dans l’objet d’erreur) ; confirmez le format actuel dans la documentation du hook afin que les utilisateurs voient un message significatif.
  • Ajoutez des délais d’attente. Les serveurs DNS lents ralentissent directement l’inscription, il faut donc limiter chaque recherche.
  • Temps de exécution : Les Edge Functions s’exécutent sur Deno, et 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-created vous 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’erreur ENODATA au lieu d’attendre un tableau vide.
  • Les vérifications DNS détectent les fautes d’orthographe et les domaines falsifiés, mais pas les boîtes aux lettres inexistantes ou inutilisées ; la confirmation par e-mail reste la seule preuve de propriété.
  • Vérifiez la signature du hook ainsi que les temps de recherche avant d’en faire usage en environnement de production.