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
includesans les compter. - DMARC en
p=nonen’arrête rien : il observe. Il ne protège qu’enquarantineoureject, 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 -allet DMARCp=rejectempêchent l’usurpation de domaines dormants.
Les trois mécanismes en une phrase
| Mécanisme | Où | Rôle | Norme |
|---|---|---|---|
| SPF | TXT sur domaine | Liste les serveurs autorisés à envoyer pour le domaine de l’enveloppe (adresse de retour). | RFC 7208 |
| DKIM | TXT sur sélecteur._domainkey.domaine | Clé publique qui vérifie la signature ajoutée au message par le serveur d’envoi. | RFC 6376 |
| DMARC | TXT sur _dmarc.domaine | Politique (none, quarantine, reject), pourcentage, adresses de rapport ; exige l’alignement avec le domaine du « From ». | RFC 7489 |

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,existset le modificateurredirect. 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 lesincludedes 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
+allou?all: tout le monde (ou personne) est autorisé ; la protection est nulle.~allest un échec « mou », acceptable pendant la mise en place,-alll’échec ferme. - DMARC
p=noneindéfiniment. C’est la bonne étape pour commencer, à condition de lire les rapports et de monter ensuite àquarantinepuisreject; la RFC prévoit le paramètrepctpour 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.

- SPF :
v=spf1 include:spf.infomaniak.ch -all. Un seul fournisseur, 3 requêtes DNS sur 10 en comptant ses propresinclude, refus par défaut. C’était correct. - DKIM : le sélecteur
20260928existe 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 sansrua: 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
- 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. - Contrôler le SPF avec le script : un seul enregistrement, 10 requêtes au plus, pas de
ptr, terminé par-all(ou~allpendant la mise en place). - Publier DMARC en
p=noneavecrua, lire les rapports quelques semaines, corriger les expéditeurs légitimes qui échouent. - Monter à
quarantinepuisreject, éventuellement avecpctpour passer par paliers. - Tester en réel : envoyer un message à une boîte tierce et lire les résultats d’authentification dans l’en-tête.
- Domaines sans courrier (anciens noms, variantes) :
v=spf1 -allet DMARCp=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'}")
À lire aussi
- MFA : pourquoi toutes les authentifications fortes ne se valent pas : l’hameçonnage commence souvent par un faux e-mail.
- Rançongiciels : agir avant le chiffrement : l’accès initial passe souvent par un message piégé.
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