SPF, DKIM, DMARC : vérifier en dix minutes si votre domaine peut être usurpé

N’importe qui peut écrire « From: direction@votre-domaine » dans un e-mail : le protocole de messagerie ne vérifie pas. Trois mécanismes publiés dans le DNS (SPF, DKIM, DMARC) permettent aux destinataires de refuser ces messages. Voici ce que chacun fait, les erreurs qui les rendent inopérants et comment contrôler un domaine en dix minutes.

Ce qu’il faut retenir

  • SPF dit qui a le droit d’envoyer, DKIM prouve que le message n’a pas été modifié, DMARC dit quoi faire quand l’un des deux échoue et exige que l’adresse affichée soit celle qui a été vérifiée (« alignement »).
  • Un SPF qui dépasse 10 requêtes DNS ne vaut plus rien : la norme impose de rendre « permerror » au-delà, et beaucoup d’enregistrements enchaînent les include sans les compter.
  • DMARC en p=none n’arrête rien : il observe. Il ne protège qu’en quarantine ou reject, et ne s’y passe qu’après avoir lu les rapports (rua).
  • Le DNS ne permet pas de vérifier DKIM seul : il faut connaître le « sélecteur », qui n’est pas listé. La preuve vient d’un message reçu (en-tête Authentication-Results).
  • Un domaine qui n’envoie pas d’e-mail devrait le dire : SPF v=spf1 -all et DMARC p=reject empêchent l’usurpation de domaines dormants.

Les trois mécanismes en une phrase

MécanismeOùRôleNorme
SPFTXT sur domaineListe les serveurs autorisés à envoyer pour le domaine de l’enveloppe (adresse de retour).RFC 7208
DKIMTXT sur sélecteur._domainkey.domaineClé publique qui vérifie la signature ajoutée au message par le serveur d’envoi.RFC 6376
DMARCTXT sur _dmarc.domainePolitique (none, quarantine, reject), pourcentage, adresses de rapport ; exige l’alignement avec le domaine du « From ».RFC 7489
SPF, DKIM et DMARC : trois boucliers qui protègent l'adresse d'expéditeur d'un domaine

L’alignement est le point que beaucoup oublient : SPF et DKIM peuvent réussir pour un autre domaine que celui que l’utilisateur voit dans « From ». C’est DMARC qui impose que le domaine vérifié soit le même (par défaut en mode « relaxed », le même domaine organisationnel ; réglable avec adkim et aspf).

Les erreurs qui rendent la protection inopérante

  • Plus de 10 requêtes DNS dans le SPF. La section 4.6.4 de la RFC 7208 liste ce qui compte : include, a, mx, ptr, exists et le modificateur redirect. Au-delà de 10, l’évaluation doit renvoyer « permerror ». Un SPF qui cumule plusieurs services d’envoi (messagerie, newsletter, CRM, support) y arrive vite, et les include des fournisseurs comptent aussi, récursivement.
  • Deux enregistrements SPF pour le même domaine : permerror également. Il n’en faut qu’un.
  • Le mécanisme ptr : la RFC écrit « do not use » et précise qu’il ne devrait pas être publié.
  • Terminaison +all ou ?all : tout le monde (ou personne) est autorisé ; la protection est nulle. ~all est un échec « mou », acceptable pendant la mise en place, -all l’échec ferme.
  • DMARC p=none indéfiniment. C’est la bonne étape pour commencer, à condition de lire les rapports et de monter ensuite à quarantine puis reject ; la RFC prévoit le paramètre pct pour passer progressivement.
  • DMARC sans rua : vous ne recevez aucun rapport, donc vous ne savez pas qui envoie en votre nom, légitimement ou non.

Le script

mailauth_check.py (bibliothèque standard de Python, requêtes DNS-over-HTTPS publiques) lit le SPF, compte récursivement les requêtes DNS, repère les erreurs ci-dessus, lit le DMARC et contrôle les sélecteurs DKIM que vous lui indiquez :

python3 mailauth_check.py exemple.fr                    # SPF + DMARC
python3 mailauth_check.py exemple.fr sel1 sel2          # + sélecteurs DKIM à tester
python3 mailauth_check.py --test                        # auto-test des analyseurs

L’auto-test construit des enregistrements fictifs et vérifie les cas qui comptent : un SPF normal (aucun avis), un SPF à 11 requêtes (permerror), la présence de ptr, un +all, deux enregistrements SPF, une boucle d’include, puis côté DMARC p=none, p=reject sans rapport et un pourcentage partiel. Résultat : 9 vérifications réussies.

Lecture d’un domaine réel : killchain.fr

Nous avons passé notre propre domaine au script le 9 octobre 2026 (enregistrements DNS publics) : deux des trois contrôles étaient corrects, un point manquait, que nous avons corrigé dans la journée.

Lecture des enregistrements SPF, DKIM et DMARC de killchain.fr après correction : SPF strict, clé DKIM publiée, DMARC en reject avec adresse de rapports
  • SPF : v=spf1 include:spf.infomaniak.ch -all. Un seul fournisseur, 3 requêtes DNS sur 10 en comptant ses propres include, refus par défaut. C’était correct.
  • DKIM : le sélecteur 20260928 existe dans la zone (20260928._domainkey.killchain.fr, clé RSA de 2048 bits). Il ne se devine pas : nous l’avons lu dans la zone DNS, que le fournisseur de messagerie remplit à la création de la boîte. Un premier passage du script avec des sélecteurs courants (default, mail…) ne trouvait rien : cela ne prouvait pas l’absence de DKIM. Présence de la clé ne veut toutefois pas dire que les messages sont signés avec : le test d’envoi reste nécessaire.
  • DMARC : c’était v=DMARC1; p=reject;, la politique la plus stricte mais sans rua : un message légitime refusé (redirection d’e-mail, nouvel outil d’envoi) serait passé inaperçu. Nous avons ajouté rua=mailto:contact@killchain.fr : v=DMARC1; p=reject; rua=mailto:contact@killchain.fr. Les rapports arrivent en pièces jointes XML compressées, en général une fois par jour et par grand fournisseur ; pour un petit domaine le volume reste faible.

Deux précisions. D’abord, les résolveurs gardent l’ancienne valeur jusqu’à une heure (durée de vie de l’enregistrement : 3600 secondes), et les deux serveurs de noms du fournisseur n’ont pas basculé exactement en même temps ; le changement est donc visible progressivement. Ensuite, le point qui reste à faire est le test d’envoi réel : écrire depuis contact@killchain.fr vers une boîte tierce, ouvrir l’en-tête du message reçu et vérifier spf=pass, dkim=pass et dmarc=pass. Nous ne l’avons pas fait pour cet article.

La leçon de l’exercice : le DNS permet de contrôler SPF et DMARC de l’extérieur, et DKIM seulement si l’on connaît le sélecteur ; aucun contrôle DNS ne remplace un test d’envoi réel.

Un plan en dix minutes

  1. Lister qui envoie du courrier en votre nom : messagerie, newsletter, facturation, CRM, outils de support, imprimantes. Un service oublié sera refusé le jour où vous passerez en reject.
  2. Contrôler le SPF avec le script : un seul enregistrement, 10 requêtes au plus, pas de ptr, terminé par -all (ou ~all pendant la mise en place).
  3. Publier DMARC en p=none avec rua, lire les rapports quelques semaines, corriger les expéditeurs légitimes qui échouent.
  4. Monter à quarantine puis reject, éventuellement avec pct pour passer par paliers.
  5. Tester en réel : envoyer un message à une boîte tierce et lire les résultats d’authentification dans l’en-tête.
  6. Domaines sans courrier (anciens noms, variantes) : v=spf1 -all et DMARC p=reject.

Ce que nous avons vérifié

Les limites et règles citées (10 requêtes, permerror, ptr, pct, rua, adkim/aspf, nom du sous-domaine _domainkey) ont été relues dans le texte des RFC 7208, 7489 et 6376. Le script et son auto-test ont été exécutés (9 vérifications réussies) et le script a été lancé sur les enregistrements réels de killchain.fr, avant et après la correction du DMARC (modification faite par l’API DNS du fournisseur, ancienne valeur sauvegardée). Les messages de rejet réels des grands fournisseurs (Google, Microsoft, etc.) varient et n’ont pas été testés ici ; leurs exigences propres pour les expéditeurs en volume dépassent ce que couvre cet article.

Transparence : la recherche, la rédaction et, lorsqu’il y en a, les tests de cet article ont été réalisés avec l’aide d’un assistant IA (Claude) ; l’éditeur du site relit et valide chaque article avant publication. La lecture de killchain.fr porte sur nos propres enregistrements DNS publics.

Le script complet

Le code complet de mailauth_check.py (bibliothèque standard de Python ; --test lance l’auto-test).

#!/usr/bin/env python3
"""mailauth_check.py DOMAINE [selecteur-dkim ...] : contrôle SPF, DMARC et (si fournis) sélecteurs DKIM d'un domaine, par DNS-over-HTTPS (Cloudflare).
Bibliothèque standard. Règles : RFC 7208 (SPF : 10 requêtes DNS au plus, ptr à ne pas publier) et RFC 7489 (DMARC).
Auto-test des analyseurs : python3 mailauth_check.py --test"""
import json, re, sys, urllib.parse, urllib.request

def txt(nom):
    u = "https://cloudflare-dns.com/dns-query?" + urllib.parse.urlencode({"name": nom, "type": "TXT"})
    r = urllib.request.Request(u, headers={"accept": "application/dns-json"})
    with urllib.request.urlopen(r, timeout=10) as f:  # nosec B310 (URL https fixe)
        d = json.load(f)
    return ["".join(re.findall(r'"((?:[^"\\]|\\.)*)"', a["data"])) or a["data"] for a in d.get("Answer", []) if a["type"] == 16]

def spf_record(nom, fetch=txt):
    r = [t for t in fetch(nom) if t.lower().startswith("v=spf1")]
    return r[0] if len(r) == 1 else (None if not r else "MULTIPLE")

def spf_analyse(domaine, fetch=txt, vus=None, compteur=None):
    """Compte les mécanismes qui coûtent une requête DNS (include, a, mx, ptr, exists, redirect), récursivement."""
    vus = vus if vus is not None else set(); compteur = compteur if compteur is not None else {"n": 0, "ptr": False, "fin": None, "erreurs": []}
    if domaine in vus:
        compteur["erreurs"].append(f"boucle sur {domaine}"); return compteur
    vus.add(domaine)
    rec = spf_record(domaine, fetch)
    if rec is None: compteur["erreurs"].append(f"pas de SPF pour {domaine}"); return compteur
    if rec == "MULTIPLE": compteur["erreurs"].append(f"plusieurs enregistrements SPF pour {domaine} (permerror)"); return compteur
    for terme in rec.split()[1:]:
        q, t = (terme[0], terme[1:]) if terme[0] in "+-~?" else ("+", terme)
        nom = t.split(":", 1)[0].split("=", 1)[0].split("/", 1)[0].lower()
        if nom == "all":
            compteur["fin"] = compteur["fin"] or (q + "all")
        elif nom in ("include", "redirect"):
            compteur["n"] += 1
            cible = t.split(":", 1)[1] if nom == "include" else t.split("=", 1)[1]
            spf_analyse(cible, fetch, vus, compteur)
        elif nom in ("a", "mx", "exists", "ptr"):
            compteur["n"] += 1
            if nom == "ptr": compteur["ptr"] = True
    return compteur

def dmarc_analyse(rec):
    tags = dict(p.strip().split("=", 1) for p in rec.split(";") if "=" in p)
    avis = []
    pol = tags.get("p", "").lower()
    if pol == "none": avis.append("politique p=none : surveillance seulement, rien n'est refusé")
    elif pol == "quarantine": avis.append("p=quarantine : les messages non alignés vont en spam")
    elif pol == "reject": avis.append("p=reject : les messages non alignés sont refusés")
    else: avis.append("politique p absente ou invalide")
    if "rua" not in tags: avis.append("pas de rua : aucun rapport agrégé reçu, vous ne voyez pas qui envoie en votre nom")
    if tags.get("pct", "100") != "100": avis.append(f"pct={tags['pct']} : la politique ne s'applique qu'à ce pourcentage")
    return tags, avis

def verdict_spf(c):
    avis = []
    if c["n"] > 10: avis.append(f"{c['n']} requêtes DNS : au-delà de 10, le résultat est « permerror » (RFC 7208)")
    elif c["n"] >= 8: avis.append(f"{c['n']} requêtes DNS sur 10 : peu de marge")
    if c["ptr"]: avis.append("mécanisme ptr publié : à ne pas utiliser (RFC 7208, 5.5)")
    if c["fin"] in ("+all", "?all"): avis.append(f"terminaison {c['fin']} : tout le monde est autorisé ou aucune décision")
    elif c["fin"] == "~all": avis.append("terminaison ~all : échec « mou » (acceptable pendant la mise en place)")
    elif c["fin"] is None: avis.append("pas de « all » final")
    avis += c["erreurs"]
    return avis

def _test():
    base = {
        "ok.test": ["v=spf1 include:_spf.a.test -all"], "_spf.a.test": ["v=spf1 ip4:192.0.2.0/24 -all"],
        "gros.test": ["v=spf1 " + " ".join(f"include:i{i}.test" for i in range(11)) + " -all"],
        "ptr.test": ["v=spf1 ptr a mx ~all"], "ouvert.test": ["v=spf1 +all"],
        "double.test": ["v=spf1 -all", "v=spf1 +all"], "boucle.test": ["v=spf1 include:boucle.test -all"],
    }
    for i in range(11): base[f"i{i}.test"] = ["v=spf1 ip4:192.0.2.1 -all"]
    f = lambda n: base.get(n, [])
    c = spf_analyse("ok.test", f); assert c["n"] == 1 and c["fin"] == "-all" and not verdict_spf(c), c
    c = spf_analyse("gros.test", f); assert c["n"] == 11 and any("permerror" in a for a in verdict_spf(c)), c
    c = spf_analyse("ptr.test", f); assert c["ptr"] and c["n"] == 3 and any("ptr" in a for a in verdict_spf(c)) and c["fin"] == "~all", c
    c = spf_analyse("ouvert.test", f); assert any("+all" in a for a in verdict_spf(c)), c
    c = spf_analyse("double.test", f); assert any("plusieurs" in a for a in verdict_spf(c)), c
    c = spf_analyse("boucle.test", f); assert any("boucle" in a for a in verdict_spf(c)), c
    t, a = dmarc_analyse("v=DMARC1; p=none; rua=mailto:r@x.test"); assert t["p"] == "none" and "surveillance" in a[0] and len(a) == 1, a
    t, a = dmarc_analyse("v=DMARC1; p=reject;"); assert any("pas de rua" in x for x in a), a
    t, a = dmarc_analyse("v=DMARC1; p=quarantine; pct=25; rua=mailto:r@x.test"); assert any("pct=25" in x for x in a), a
    print("auto-test OK : 9 vérifications (SPF : normal, 11 requêtes, ptr, +all, doublon, boucle ; DMARC : none, reject sans rua, pct)")

if __name__ == "__main__":
    if sys.argv[1:2] == ["--test"]: _test(); sys.exit()
    dom = sys.argv[1]; selecteurs = sys.argv[2:]
    s = spf_record(dom)
    print(f"SPF   : {s}")
    if s and s != "MULTIPLE":
        c = spf_analyse(dom)
        print(f"        {c['n']} requête(s) DNS sur 10, terminaison {c['fin']}")
        for a in verdict_spf(c): print("        !", a)
    d = [t for t in txt("_dmarc." + dom) if t.lower().startswith("v=dmarc1")]
    print(f"DMARC : {d[0] if d else 'absent'}")
    if d:
        for a in dmarc_analyse(d[0])[1]: print("        !", a)
    for sel in selecteurs:
        k = txt(f"{sel}._domainkey.{dom}")
        print(f"DKIM  : {sel} -> {'présent (' + str(len(''.join(k))) + ' car.)' if k else 'absent'}")

Sources

  • RFC 7208, « Sender Policy Framework (SPF) » : rfc-editor.org
  • RFC 6376, « DomainKeys Identified Mail (DKIM) Signatures » : rfc-editor.org
  • RFC 7489, « Domain-based Message Authentication, Reporting, and Conformance (DMARC) » : rfc-editor.org