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=spf1 TXT records is a permanent error (permerror). Merge them.
  • Ten DNS lookups maximum. include, a, mx, ptr, exists and redirect each 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

  1. Inventory senders: your mail platform, CRM, helpdesk, billing system, marketing tools. Each needs SPF inclusion or, better, DKIM with your domain.
  2. Publish p=none with rua and read reports for a few weeks. Fix every legitimate source that fails alignment.
  3. Move to p=quarantine, optionally with pct= to apply it to a fraction of failing mail first, and keep watching reports.
  4. Move to p=reject once failures are only unknown sources.
  5. 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.