Blockieren von nicht zugestellten Registrierungs-E-Mails mithilfe eines Supabase Auth Hooks und DNS
Verwenden Sie einen vor dem Benutzer erstellten Supabase-Hook sowie MX-, A- und AAAA-Lookups, um Anmeldungen abzulehnen, deren E-Mail-Domain gemäß den Regeln von RFC 5321 keine E-Mails empfangen kann.
Ein Anmeldeformular, das E-Mails nur mit einer regulären Ausdrucksregel überprüft, akzeptiert alles, was wie eine Adresse aussieht – einschließlich Adressen auf Domains, die überhaupt keine E-Mails empfangen können. Da Supabase das Benutzerkonto erst erstellt, nachdem auf die Bestätigungs-E-Mail geklickt wurde, hinterlässt jede solche Adresse eine unüberprüfte Zeile in Ihrer Datenbank. Diese Anleitung zeigt, wie man Anmeldungen mit einem Supabase Auth-Hook abfangen und DNS-Aufrufe nutzen kann, um Domains mit keinem funktionierenden Mail-Server abzulehnen, was die relevanten SMTP-Regeln vorschreiben, und wo diese Vorgehensweise vor der Veröffentlichung weiter verstärkt werden muss.
Wo man sich in den Anmeldeprozess einbinden kann
Mit Supabase kommen Anmeldeanfragen in der Regel direkt von der Client-Bibliothek, sodass es keine eigene Serverroute gibt, an der man die Adresse zuerst überprüfen könnte. Supabase löst dieses Problem mit Auth Hooks: Endpunkte oder Datenbankfunktionen, die von Supabase Auth zu bestimmten Zeitpunkten in seinem Ablauf aufgerufen werden. Der before-user-created-Hook wird vor dem Einfügen eines neuen Benutzers ausgeführt und kann die Anfrage zulassen oder ablehnen.
Die untenstehenden Beispiele stammen aus einem Next.js-Projekt, doch die Überprüfung selbst findet in einer Supabase Edge Function statt, wodurch sie nicht von Ihrem Frontend-Framework abhängt.
Der Hook in einem lokalen Projekt aktivieren
Wenn Supabase über die CLI lokal ausgeführt wird, bietet die Studio-Oberfläche möglicherweise keine Einstellung für Auth-Hooks. In diesem Fall aktivieren Sie den Hook in supabase/config.toml, indem Sie diesen Abschnitt dekommentieren und ausfüllen:
#[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 gibt den Endpunkt an, den Supabase aufrufen wird, und secrets enthält das Signierungssecret, das Supabase verwendet, um jede Hook-Anfrage zu signieren. Beachten Sie den Host: host.docker.internal wird verwendet, weil der lokale Supabase-Stack in Docker läuft, und dieser Name ermöglicht es den Containern, auf den Functions-Server zuzugreifen.
Erstellen einer Edge-Funktion
Der Endpunkt hier ist eine Edge-Funktion, eine serverseitige Funktion, die Supabase in einem weltweit verteilten Laufzeitumfeld in der Nähe der Benutzer bereitstellt. Erstellen Sie eine mit der CLI:
npx supabase functions new before-user-created
Der Befehl erstellt einen neuen Ordner unter supabase/functions, und die Logik kommt in dessen index.ts. Laut der Supabase-Dokumentation zum Hook „before-user-created“ sendet Supabase eine Nutzlast mit Anfragedaten sowie Informationen über den Benutzer, der erstellt werden soll:
{
"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
}
}
Das für diese Überprüfung wichtige Feld ist user.email.
Bis wohin sollte die Validierung gehen?
Sobald man die E-Mail-Adresse hat, kann deren Domain über DNS überprüft werden. Man könnte noch weiter gehen: Die IP-Adresse des Mailserverns auflösen, eine TCP-Verbindung dazu herstellen und die SMTP-Kommunikation prüfen. Das lohnt sich in der Regel jedoch nicht. Viele Server lehnen solche Anfragen ab oder geben falsche Antworten, die Überprüfungen verursachen erhebliche Verzögerungen bei jeder Registrierung, und der einzige zuverlässige Beweis dafür, dass ein Postfach existiert, ist, wenn der Benutzer auf den Bestätigungslink klickt.
Ein pragmatischer Kompromiss besteht darin, zu überprüfen, ob die Domain mindestens einen Mail-Server hat, der tatsächlich auf eine IP-Adresse verweist. Dadurch lassen sich kostengünstig Tippfehler sowie erfundene Domains ausschließen. Jemand, der darauf aus ist, Ihre Datenbank zu verschmutzen, kann dennoch echte Domains registrieren – betrachten Sie dies daher eher als Filter gegen Störungen denn als Schutz vor Missbrauch. Inwieweit man darüber hinausgeht, hängt von Ihrem Bedrohungsmodell ab.
Was die SMTP-Regeln zu MX-Einträgen sagen
Domains geben ihre Mail-Server als MX-(Mail-Exchange)-Einträge an, wie in RFC 5321 für SMTP definiert. Nodes dns-Modul stellt resolveMx() zur Verfügung, das den exchange-Host sowie die priority jedes Eintrags zurückgibt, sowie resolve4() und resolve6(), die die IPv4- und IPv6-Adressen eines Hosts liefern. Drei Regeln bestimmen, ob eine Domain E-Mails empfangen kann:
- Fehlende MX-Einträge bedeuten einen impliziten MX-Eintrag. Der Abschnitt zur Bestimmung des Zielhosts in der RFC besagt, dass bei einem leeren MX-Listenverzeichnis die Domain selbst als Mailserver betrachtet wird. Daher entscheiden die eigenen A- oder AAAA-Einträge der Domain über das Ergebnis.
- MX-Einträge, die alle nicht nutzbar sind, stellen einen Fehler dar. Derselbe Abschnitt verlangt, dass bei vorhandenen MX-Einträgen, von denen keiner funktioniert, die Zustellung fehlschlagen muss. In der Praxis: Wenn keine aus der Liste genannte Exchange-Adresse auf eine IP-Adresse auflösbar ist, muss die Anmeldung abgelehnt werden.
Die Implementierung des Hooks
Die untenstehende Funktion wendet diese Regeln an. Sie liest den Payload, extrahiert die Domain nach dem @ und gibt bei fehlerhafter Eingabe 400 zurück. Anschließend sucht sie nach MX-Einträgen und handhabt jeden Fall: Fehlen solche Einträge, wird auf die eigenen Adressen der Domain geprüft; bei einem einzigen ungültigen MX-Eintrag wird die Anfrage abgelehnt; ansonsten werden die Exchange-Server durchlaufen, und sobald einer eine IP-Adresse hat, wird dies akzeptiert. Ein leeres JSON-Objekt signalisiert an Supabase, mit der Verarbeitung fortzufahren. Ein Hilfsfunktion hasIpAddress prüft IPv4- und IPv6-Adressen parallel mithilfe von Promise.allSettled, sodass ein Misserfolg bei einer Suche den Erfolg bei der anderen nicht verdeckt.
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);
};
Problemstellen in diesem Code, die behoben werden sollten
Die Logik folgt den RFCs, doch einige Details verhalten sich anders, als der Code annimmt:
resolveMx()wirft einen Fehler aus anstelle einer leeren Liste zurückzugeben. Bei einem Domainnamen ohne MX-Einträge oder einer nicht existierenden Domain lehnt Node die Anfrage mit Fehlern wieENODATAoderENOTFOUNDab. Das bedeutet, dass der implizite MX-Pfad nur selten ausgeführt wird; stattdessen gibt dascatch-Blöck ein 500-Fehlermeldung aus, und legitime Domains, die auf einen impliziten MX angewiesen sind, werden als Serverfehler abgelehnt. Fangen SieENODATA-Fehler umresolveMx()herum ab und weichen Sie aufhasIpAddress(domain)aus.- Die Überprüfung auf null MX-Einträge stimmt möglicherweise nicht überein. Node meldet einen null MX-Eintruck mit einem leeren
exchange-String anstelle von „.“. Der Schutzmechanismusif (record.exchange)in der Schleife lehnt solche Domains dennoch ab, aber vergleichen Sie beide Werte, um die Absicht klarzustellen.
auth: „none“ akzeptiert jeden Aufrufer. Überprüfen Sie die signierte Anfrage mithilfe des in config.toml konfigurierten Geheimnisses, wie es in der Dokumentation zu den Auth-Hooks beschrieben wird, damit Außenstehende die Funktion nicht aufrufen können.node:dns/promises funktioniert über seine Node-Kompatibilitätsschicht. Testen Sie dies in Ihrem Deployment-Ziel.Erlauben von nicht authentifizierten Aufrufen der Funktion
Zum Schluss registrieren Sie die Funktion in config.toml und deaktivieren Sie die JWT-Verifizierung für sie. Der Name in den Klammern muss mit dem Ordnungsnamen der Funktion in supabase/functions übereinstimmen:
[functions.before-user-created]
verify_jwt = false
Die JWT-Verifizierung ist deaktiviert, weil zum Zeitpunkt der Registrierung noch niemand angemeldet ist; die eigene Signaturprüfung des Hooks, und nicht ein Benutzertoken, sollte diesen Endpunkt schützen.
Wichtige Erkenntnisse
- Ein Auth-Hook von Typ
before-user-createdermöglicht es Ihnen, Adressen auf Serverseite zu validieren, selbst wenn die Registrierung über die Client-Bibliothek ausgelöst wird. - Ein Domainname kann E-Mails erhalten, wenn einer seiner MX-Hosts erreichbar ist – oder, falls es keine MX-Einträge gibt, wenn der Domainname selbst erreichbar ist; ein nuller MX-Wert bedeutet, dass keine E-Mails akzeptiert werden.
- In Node wirft
resolveMx()bei fehlenden Einträgen einen Fehler aus, daher sollten Sie explizit aufENODATAreagieren anstelle davon, mit einem leeren Array zu rechnen.