Email Security: SPF, DKIM and DMARC
Proving an Email Really Came From Your Domain
SMTP was designed without sender authentication. Any server on the internet can connect to a recipient's mail server and claim to be sending on behalf of your domain, and the protocol itself will not object. SPF, DKIM and DMARC are three DNS-published mechanisms, added over the years, that let receivers check that claim. Together they stop exact-domain spoofing of your brand, the raw material of invoice fraud and phishing (social engineering covers the human side).
Two "From" Addresses
A spoofed message is easy to send because a message carries two sender identities. Here is the SMTP dialogue (the full protocol is in SMTP, IMAP and POP3):
S: 220 mx.example.net ESMTP
C: EHLO mail.attacker.example
C: MAIL FROM:<bounce@attacker.example> <- envelope sender (Return-Path)
C: RCPT TO:<alice@example.net>
C: DATA
S: 354 End data with <CR><LF>.<CR><LF>
C: From: "Example Billing" <billing@example.com> <- header From, what Alice sees
C: Subject: Updated bank details
...
S: 250 OK: queued
The envelope sender (MAIL FROM) is where bounces go. The header From is what the mail client displays. Nothing in SMTP requires them to match, or requires either to be true. The three mechanisms each check a different thing:
| Mechanism | What it proves | Domain it checks | Typical breakage |
|---|---|---|---|
| SPF | The sending IP is allowed to send for a domain | Envelope sender (MAIL FROM), or HELO name |
Forwarding: the forwarder's IP is not in your record |
| DKIM | The signed headers and body were not changed since a domain signed them | The d= domain in the signature |
Mailing lists that edit the subject or append a footer |
| DMARC | SPF or DKIM passed for a domain aligned with the header From, plus a policy and reporting | Header From | Third-party senders you forgot to configure |
SPF and DKIM on their own never look at the address the user sees: an attacker can pass SPF for attacker.example while displaying billing@example.com. DMARC closes that gap with alignment.
SPF: Which Servers May Send
SPF is a TXT record at the domain used in MAIL FROM:
example.com. TXT "v=spf1 ip4:192.0.2.0/24 include:_spf.google.com include:mailgun.org ~all"
Terms are evaluated left to right. ip4/ip6 match literal ranges; include: pulls in another domain's SPF result (how you authorise an email provider); a and mx match your own A and MX hosts. The final all catches everything else, and its qualifier sets the result: -all fail, ~all softfail, ?all neutral. +all authorises the whole internet and should never appear.
The rules that bite in practice:
- One record only. Two
v=spf1TXT records is a permanent error (permerror). Merge them. - Ten DNS lookups maximum.
include,a,mx,ptr,existsandredirecteach cost one, nested includes included. Exceeding ten is a permerror, often introduced silently when someone adds one more vendor. - Forwarding breaks SPF, which is why DMARC accepts DKIM as an alternative.
Counting lookups by hand across nested includes is error-prone. A short script with dnspython does it:
import dns.resolver
def get_spf(domain):
try:
answers = dns.resolver.resolve(domain, "TXT")
except (dns.resolver.NXDOMAIN, dns.resolver.NoAnswer):
return None
spf = [b"".join(r.strings).decode() for r in answers]
spf = [r for r in spf if r.lower().startswith("v=spf1 ")]
if len(spf) > 1:
raise ValueError(f"{domain}: {len(spf)} SPF records -> permerror")
return spf[0] if spf else None
def count_lookups(domain, depth=0):
terms = [t.lstrip("+-~?").lower() for t in (get_spf(domain) or "").split()[1:]]
costly = [t for t in terms if t in ("a", "mx")
or t.startswith(("a:", "mx:", "ptr", "exists:", "include:", "redirect="))]
print(" " * depth + f"{domain}: {' '.join(costly) or '-'}")
total = len(costly)
for t in costly:
if t.startswith(("include:", "redirect=")):
total += count_lookups(t.replace("=", ":").split(":", 1)[1], depth + 1)
return total
print("total:", count_lookups("python.org"), "of 10 allowed")
Output at the time of writing (records change):
python.org: mx include:stspg-customer.com include:_spf.google.com include:mailgun.org
stspg-customer.com: -
_spf.google.com: -
mailgun.org: include:_spf.mailgun.org include:_spf.eu.mailgun.org
_spf.mailgun.org: include:_spf1.mailgun.org include:_spf2.mailgun.org
_spf1.mailgun.org: -
_spf2.mailgun.org: -
_spf.eu.mailgun.org: -
total: 8 of 10 allowed
Four visible terms cost eight lookups, because one provider nests more. Run a check like this whenever your record changes. (It ignores SPF macros such as %{i}, which expand per message.)
DKIM: A Signature Over the Message
With DKIM, the sending server signs selected headers and a hash of the body with a private key, and publishes the public key in DNS at <selector>._domainkey.<domain>. The receiver fetches the key and verifies. It is an application of digital signatures, and you can watch it work offline with dkimpy and a stand-in for the DNS lookup:
import base64
import dkim
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import rsa
key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
private_pem = key.private_bytes(serialization.Encoding.PEM,
serialization.PrivateFormat.TraditionalOpenSSL,
serialization.NoEncryption())
public_der = key.public_key().public_bytes(serialization.Encoding.DER,
serialization.PublicFormat.SubjectPublicKeyInfo)
txt_record = b"v=DKIM1; k=rsa; p=" + base64.b64encode(public_der)
def fake_dns(name, timeout=5): # stands in for the TXT lookup
return txt_record if name == b"s1._domainkey.example.com." else None
msg = (b"From: Billing <billing@example.com>\r\nTo: alice@example.net\r\n"
b"Subject: Invoice 1042\r\n\r\nPlease pay to account 12-3456.\r\n")
sig = dkim.sign(msg, b"s1", b"example.com", private_pem,
include_headers=[b"from", b"to", b"subject"])
signed = sig + msg
print(sig.decode())
print("original:", dkim.verify(signed, dnsfunc=fake_dns))
print("body changed:", dkim.verify(signed.replace(b"12-3456", b"99-9999"), dnsfunc=fake_dns))
DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/simple; d=example.com;
i=@example.com; q=dns/txt; s=s1; t=1790751398; h=from : to : subject;
bh=hq8MseCd96ZsQmUKJV82p+CII05i5kqfir6P3Pg2Emw=;
b=Mu3UBHC1NqEKuYoswb+76OW9nlRLy3IlSZI8mT1xlfdsBGRlLMzglKr+H+2rRDRWWB1IH
...
original: True
body changed: False
d= is the signing domain (the one DMARC compares), s= the selector, h= the signed headers, bh= the body hash, b= the signature, and c= the canonicalisation, which decides how much whitespace change is tolerated. Changing one digit of the account number breaks the signature.
In production: use 2048-bit keys, give each sending service its own selector so one can be rotated or revoked alone, and avoid the l= body-length tag, which lets anyone append content below the signed part.
DMARC: Alignment, Policy and Reports
DMARC is a TXT record at _dmarc.<domain>:
_dmarc.example.com. TXT "v=DMARC1; p=quarantine; rua=mailto:dmarc@example.com; adkim=r; aspf=r"
A message passes DMARC if either SPF passes for a domain aligned with the header From, or a DKIM signature verifies with a d= aligned with it. Relaxed alignment (r, the default) accepts the same organisational domain, so mail.example.com aligns with example.com; strict (s) needs an exact match.
p= tells receivers what to do on failure: none (monitor only), quarantine (spam folder) or reject. sp= sets a separate policy for subdomains, and rua= is where receivers send daily aggregate reports.
Those reports are XML (usually zipped or gzipped attachments) listing, per sending IP, how many messages claimed your domain and how they fared. Parsing one shows why alignment matters:
import xml.etree.ElementTree as ET
root = ET.parse("report.xml").getroot()
print("reporter:", root.findtext("report_metadata/org_name"),
"| policy:", root.findtext("policy_published/p"))
for rec in root.iter("record"):
ip = rec.findtext("row/source_ip")
count = int(rec.findtext("row/count"))
dkim = rec.findtext("row/policy_evaluated/dkim") # aligned DKIM result
spf = rec.findtext("row/policy_evaluated/spf") # aligned SPF result
raw_spf = rec.findtext("auth_results/spf/domain"), rec.findtext("auth_results/spf/result")
verdict = "PASS" if "pass" in (dkim, spf) else "FAIL"
print(f"{ip:15} {count:5} dmarc={verdict} dkim={dkim} spf={spf} raw spf={raw_spf}")
reporter: receiver.example | policy: none
192.0.2.10 1250 dmarc=PASS dkim=pass spf=pass raw spf=('example.com', 'pass')
198.51.100.7 310 dmarc=FAIL dkim=fail spf=fail raw spf=('bounces.newsletter-vendor.example', 'pass')
203.0.113.66 42 dmarc=FAIL dkim=fail spf=fail raw spf=('example.com', 'softfail')
The first row is your own server. The second is a newsletter vendor: SPF passed, but for the vendor's bounce domain, which does not align with example.com, and the vendor is not DKIM-signing as you. Fix it (vendor DKIM with your domain, or a custom return-path) before enforcing. The third passes nothing and is unknown: probably spoofing, which enforcement will stop. At volume, feed reports into a parser and dashboard rather than reading XML.
Rolling Out Without Losing Real Mail
- Inventory senders: your mail platform, CRM, helpdesk, billing system, marketing tools. Each needs SPF inclusion or, better, DKIM with your domain.
- Publish
p=nonewithruaand read reports for a few weeks. Fix every legitimate source that fails alignment. - Move to
p=quarantine, optionally withpct=to apply it to a fraction of failing mail first, and keep watching reports. - Move to
p=rejectonce failures are only unknown sources. - Lock down unused domains: parked domains are spoofed precisely because nobody watches them. Publish
v=spf1 -all,v=DMARC1; p=reject;and a null MX (MX 0 .).
For bulk senders this is now mandatory: since 2024 Google and Yahoo require SPF, DKIM and a published DMARC policy with alignment, and Microsoft announced similar rules for its consumer mailboxes in 2025.
Checking Records and Real Messages
dig +short TXT example.com | grep spf1
dig +short TXT _dmarc.example.com
dig +short TXT s1._domainkey.example.com # selector comes from a message's s= tag
The receiver records its verdicts in the Authentication-Results header. View the original source of a message you sent to a mailbox you control:
Authentication-Results: mx.example.net;
dkim=pass header.i=@example.com header.s=s1;
spf=pass smtp.mailfrom=bounce.example.com;
dmarc=pass (p=REJECT) header.from=example.com
If dmarc=fail while spf=pass, compare smtp.mailfrom with header.from: that is alignment, not a missing record. dkim=fail (body hash did not verify) usually means something modified the body in transit, such as a list footer or a gateway rewriting links. Record fixes take effect as cached answers expire (DNS explains TTLs).
What These Controls Do Not Stop
DMARC protects the exact domain. It does nothing against:
- Look-alike domains (
examp1e.com,example-billing.com), which can publish perfect SPF, DKIM and DMARC of their own. Monitor new registrations resembling your brand. - Display-name spoofing:
"Example Billing" <random@freemail.example>passes everything, for the freemail domain. - Compromised accounts: mail from a real, hijacked mailbox authenticates correctly.
Forwarding and mailing lists remain the main source of false failures; ARC (Authenticated Received Chain) lets intermediaries vouch for results they saw before modifying a message. For encryption between mail servers, MTA-STS is the companion standard.