Network
SPF, DKIM and DMARC: how email authentication works
SPF, DKIM and DMARC are three DNS-published mechanisms that each answer a different question, and mail fails when people treat them as one setting. This article explains which identity each one checks, how DMARC alignment combines them, why an SPF record can be valid and still be ignored, and how each failure looks in practice. Worked examples come from running real record strings through the parsers behind the MaffeiTools email checkers.
By the MaffeiTools team · Updated · 9 min read
Three identities in one message, and which protocol vouches for which
A single delivered message carries three separate domain names. The envelope sender is given in the SMTP MAIL FROM command; bounces go there, and it is also called the return-path. The HELO or EHLO name is what the connecting server calls itself. The header From is the address the recipient's mail client displays. SMTP does not require any of the three to match, and only the third is what a person sees.
SPF (RFC 7208) checks the connecting IP address against the policy published by the domain in the envelope sender, and can also check the HELO name. DKIM (RFC 6376) checks a cryptographic signature and attributes the message to whichever domain put its name in the d= tag of the signature. Neither one looks at the header From. That is the gap DMARC exists to close. RFC 9989 points out that any domain owner, even a bad actor, can publish an SPF record and send mail that passes it, and that a message can carry a valid signature from any domain. So DMARC does not ask whether SPF or DKIM passed. It asks whether a passing identity is aligned with the domain in the header From.
SPF: a list of permitted senders, evaluated left to right
An SPF policy is a TXT record at the domain that begins with v=spf1. A receiver reads its terms left to right, and the first mechanism that matches decides the result. The optional prefix sets that result: plus for pass (the default), minus for fail, tilde for softfail, question mark for neutral. The address mechanisms ip4 and ip6 list ranges directly. The a and mx mechanisms permit the domain's own address records or mail hosts. The include mechanism evaluates another domain's policy and matches only if that policy passes; only its final verdict is used, so a -all inside the included record does not end the outer evaluation. The exists mechanism tests whether a DNS name resolves. The ptr mechanism is deprecated: RFC 7208 says it SHOULD NOT be published. The all mechanism matches everything, so it belongs last.
If nothing matches and there is no redirect modifier, the result is neutral (section 4.7), so a record with no all term protects nothing. A domain must publish exactly one v=spf1 record. Two of them produce permerror, an evaluation failure that means the SPF result is unusable. Adding a second vendor by publishing a second record is a classic cause.
The choice between ~all and -all is less clear-cut than it looks. Fail is an explicit statement that the sender is not authorized, and what to do about it is local policy. Softfail is weaker: RFC 7208 says receivers SHOULD NOT reject on it alone. RFC 9989 section 7.1 warns that with -all some receivers reject during the SMTP conversation, before any DKIM or DMARC evaluation, so mail that would have passed through an aligned DKIM signature is lost and never shows up in aggregate reports. For a domain that also publishes DMARC and signs with DKIM, ~all lets DMARC make the decision. For a domain that sends no mail, v=spf1 -all is the natural statement.
The limit of ten DNS lookups, counted the way the SPF checker counts it
RFC 7208 section 4.6.4 lists the terms that cause DNS queries: include, a, mx, ptr, exists and the redirect modifier. An evaluator must stop at ten of them and return permerror. The ip4, ip6 and all mechanisms cost nothing. The count is over the whole evaluation, so lookups inside included records count too. Each vendor's include typically hides several more. The same section separately caps address queries per MX at ten and recommends stopping at two void lookups, meaning queries that return nothing.
The SPF checker fetches the record, follows every include and redirect, and charges one lookup per lookup-costing term, printing the resulting tree. It counts every term in the record whether or not a real message would reach it, so its figure is the worst case, while a real evaluation ends at the first match. It does not follow macros, since those need a real sending IP, and it charges each mx term a flat one lookup without modeling the per-MX address limit or void lookups. It reports a warning at eight lookups or more and an error above ten.
The example below runs the checker twice. The second run adds a single a mechanism to a helpdesk vendor's record, which takes the total from ten, legal, to eleven. A sender whose evaluation has to walk that far then gets permerror instead of a pass. The names use the reserved .example suffix, and the DNS answers were supplied to the checker directly rather than fetched.
TXT answers supplied:
example.com v=spf1 include:_spf.mailer.example include:_spf.crm.example include:_spf.helpdesk.example include:_spf.news.example -all
_spf.mailer.example v=spf1 include:_n1.mailer.example include:_n2.mailer.example -all
_n1.mailer.example v=spf1 a:relay.mailer.example ip4:192.0.2.0/24 -all
_n2.mailer.example v=spf1 mx ip4:198.51.100.0/24 -all
_spf.crm.example v=spf1 a mx -all
_spf.helpdesk.example v=spf1 ip4:203.0.113.0/24 -all
_spf.news.example v=spf1 ip4:203.0.113.128/25 -all
run 1: totalLookups 10, exceedsLimit false, errors []
warning: "10 of 10 DNS lookups are used. There is little headroom left — adding
one more vendor may break SPF."
run 2 (_spf.helpdesk.example becomes v=spf1 ip4:203.0.113.0/24 a:smtp.helpdesk.example -all): totalLookups 11, exceedsLimit true
include:_spf.mailer.example
include:_n1.mailer.example
a:relay.mailer.example
include:_n2.mailer.example
mx
include:_spf.crm.example
a
mx
include:_spf.helpdesk.example
a:smtp.helpdesk.example
include:_spf.news.example
error: "This record requires 11 DNS lookups, over the RFC 7208 limit of 10. Receivers
return permerror and the SPF result is discarded. Flatten or remove includes to get
back under the limit."
two v=spf1 TXT records at example.com (v=spf1 include:_spf.mailer.example -all and v=spf1 include:_spf.crm.example -all):
error: "2 SPF records published. RFC 7208 §4.5 requires exactly one — evaluators
return permerror and treat all mail as unauthenticated. Merge them into a single record."DKIM: what gets signed, where the key lives, and what breaks it
A signing server computes two hashes. The first covers the message body after canonicalization and is stored in the bh= tag. The second covers the header fields named in the h= tag, followed by the DKIM-Signature header itself with its b= value treated as empty, and the signature over that is stored in b=. The From header must be among the signed fields (RFC 6376). The signature also names its domain in d= and a selector in s=, and a verifier fetches the public key from a TXT record at the selector, then _domainkey, then the domain. A selector such as s1 for example.com is looked up at s1._domainkey.example.com. Selectors let one domain publish several keys at once, which is how key rotation and multiple sending services coexist.
Because verification recomputes the hashes, anything that changes signed content after signing breaks the signature. Canonicalization decides how much change is tolerated. The default is simple/simple, which tolerates almost none. The relaxed algorithm tolerates whitespace changes and header re-wrapping. A mail gateway that appends a legal footer, rewrites links or re-encodes the body changes the body hash, and the verifier reports a body-hash mismatch. A signature that used the l= tag to cover only a body prefix can survive an appended footer, but RFC 6376 section 8.2 warns that the tag lets an intermediary append content to a signed message.
Key strength has a floor. RFC 8301 forbids rsa-sha1 for signing or verifying, requires signers to use RSA keys of at least 1024 bits, recommends 2048, and says verifiers must not treat signatures from keys under 1024 bits as valid. The DKIM checker needs the selector, since DNS cannot list them, and it offers common ones as suggestions. It decodes the key to read its size, and flags an empty p= as a revoked key, test mode as no protection, and sha1-only keys as deprecated.
v=DKIM1; k=ed25519; p=VEqGG5CIfL24f9O9JcBUzAwZKfk2w7mMb1E4/lkTk5E= valid true, keyType ed25519, keyBits 256, no errors, no warnings v=DKIM1; k=rsa; p=<1024-bit key> valid true, keyBits 1024 warning: "The key is 1024 bits. That is still accepted, but 2048 bits is the current recommendation." v=DKIM1; k=rsa; p=<768-bit key> valid false, keyBits 768 error: "The key is only 768 bits. RFC 8301 requires at least 1024 bits — many receivers reject signatures from weaker keys outright. Regenerate at 2048 bits." v=DKIM1; k=rsa; p= valid false, revoked true error: "The public key is empty (p=). This is the RFC 6376 way of revoking a key — every signature made with this selector will now fail. If mail is still being signed with it, DKIM is broken for this domain." v=DKIM1; k=ed25519; t=y; p=<same key> warning: "Test mode is enabled (t=y). Receivers must treat verification failures as if the message were unsigned, so this key provides no real protection. Remove t=y once testing is done."
DMARC: alignment turns two weak signals into a policy
A DMARC policy is a TXT record at _dmarc under the author domain, the domain in the header From. A message passes DMARC if at least one authenticated identifier aligns with that domain. The SPF identifier is the domain in the envelope sender, and DMARC uses only that identity, not HELO. The DKIM identifier is the d= domain of a signature that verified. Relaxed alignment, the default for both, requires the two to share an organizational domain, so mail.example.com aligns with news.example.com. Strict alignment, set with aspf=s or adkim=s, requires the names to be identical. Passing on either mechanism is enough.
The p tag says what the domain owner asks receivers to do with failures: none for monitoring, quarantine for suspicious, reject for refuse. RFC 9989 stresses that these are requests that feed into the receiver's own decision. The rua tag names where aggregate reports go, and it is the reason to start at p=none: the reports show who is sending as the domain, and whether each stream passes and aligns. The ruf tag asks for per-message failure reports; the checker warns that they can contain message content and that few receivers send them.
The DMARC standard changed recently. RFC 7489 (2015, Informational) has been obsoleted by RFC 9989, with reporting split into RFC 9990 and RFC 9991. The pct tag was removed, because implementations applied values other than 0 and 100 inconsistently; a t tag with y or n replaces the useful part. New tags include np for non-existent subdomains, and the Public Suffix List gave way to a DNS tree walk for finding the organizational domain. The DMARC checker still follows the older tag set: it recognizes pct, ri and rf, does not know t or psd, and queries only _dmarc at the exact domain you enter, so a subdomain with no record of its own reports as having no DMARC even though receivers would apply the parent's policy. Its explanation of pct also says the unselected share is treated as p=none, whereas RFC 7489 tells receivers to treat sampled-out reject mail as quarantine.
v=DMARC1; p=quarantine; pct=25; rua=mailto:dmarc@example.com; adkim=s valid true, pct 25 warning: "pct=25 means only 25% of failing mail gets the quarantine policy — the remainder is delivered as if p=none. This is a rollout tool; remove it once you are confident." "This domain: mail that fails DMARC is delivered to the spam/junk folder (applied to 25% of failing mail; the rest is treated as p=none)." "Alignment: DKIM is strict — the DKIM d= domain must match the From domain exactly; SPF is relaxed — a subdomain of the From domain is accepted." "Aggregate reports are sent to mailto:dmarc@example.com." v=DMARC1; p=reject; t=y; rua=mailto:dmarc@example.com valid true warning: "Unknown tag "t" — receivers ignore tags they do not recognise."
Four failures and how each one looks
Most breakage falls into a small set of patterns. Each has a distinct symptom, which makes them identifiable from headers and reports.
- Too many SPF lookups: permerror for any message whose evaluation reaches the term that crosses the limit. The record itself parses fine, so eyeballing it misses the problem. Only counting the expansion finds it.
- Forwarding: an alias or forwarder relays the message from its own IP address, which your SPF record does not list, so SPF fails even though the mail is legitimate. DKIM survives relaying as long as content is untouched, which is why RFC 9989 says a domain publishing p=reject must not rely solely on SPF.
- Body-hash mismatch: a gateway or mailing list edits the body or subject after signing. DKIM fails, the list's own SPF does not align with your domain, and DMARC fails. RFC 9989 section 7.4 advises that domains whose users post to mailing lists should not publish p=reject.
- Third-party senders: a vendor sends with its own return-path domain and signs with its own d= domain. SPF and DKIM both pass, but for the vendor's domain, so neither aligns with your From address and DMARC fails. The fix is configuring the vendor to sign with a key under your domain, or to use a return-path subdomain of yours.
Rolling out enforcement, and what a passing check does not prove
RFC 9989 describes the order: publish p=none with a rua address, read the reports, fix each unaligned stream, and only then consider enforcement. It notes that gaining confidence can take many months, and for domains whose users post to mailing lists it suggests at least a month at p=none and another at p=quarantine before any reject. Check each step with the tools: the SPF checker for the lookup count and the single-record rule, the DKIM checker for each selector in use, the DMARC checker for the policy and tags, and the MX record lookup to see which hosts receive mail for the domain, since an mx mechanism in SPF authorizes those same hosts to send.
Authentication says who sent a message, not whether recipients trust it. The DNSBL blacklist checker tests one IPv4 address against eleven public blocklists by reversing its octets and querying each list's zone. A listing points to reputation trouble that a perfect SPF, DKIM and DMARC setup cannot fix. Some zones refuse queries that arrive through public resolvers, and the tool then reports the zone as not checked instead of as clean. IPv6 addresses are not supported.
Sources
- RFC 7208: Sender Policy Framework (SPF) for Authorizing Use of Domains in Email, Version 1
- RFC 6376: DomainKeys Identified Mail (DKIM) Signatures
- RFC 8301: Cryptographic Algorithm and Key Usage Update to DKIM
- RFC 9989: Domain-Based Message Authentication, Reporting, and Conformance (DMARC)
- RFC 7489: DMARC (the 2015 specification, now obsolete; section 6.6.4 on pct sampling)
- RFC 9990: DMARC Aggregate Reporting
- RFC 9991: DMARC Failure Reporting