I build a transactional email API, which means I have spent an unreasonable amount of my life reading DMARC aggregate reports and explaining why password reset emails land in spam even though "nothing changed."
Most email authentication documentation is either a vendor wizard telling you to paste a record, or an RFC. This is the middle thing I wanted when I started: what each mechanism does, what breaks, and the order to do it in.
Why any of this exists
SMTP was designed in 1982 with no concept of authentication. A mail server accepts a connection and believes what it is told. Concretely, a single message carries two different sender addresses:
MAIL FROM:<bounces@sendinginfra.example> <-- the envelope sender (SMTP-level)
RCPT TO:<you@gmail.com>
DATA
From: Support <support@yourbank.com> <-- the header From (what the user sees)
Subject: Please confirm your account
The envelope sender is a routing detail: it is where bounces go. The From: header is just a line of text inside the message. Nothing in SMTP connects them. Your mail client displays the second and ignores the first.
So the whole attack is: connect to Gmail from any VPS, put From: support@yourbank.com in the headers, done. Every mechanism below closes part of that gap.
- SPF authorises the envelope sender's domain against the connecting IP.
- DKIM cryptographically signs the message, independent of the connection.
-
DMARC ties either of those back to the visible
From:domain and tells receivers what to do when neither matches.
You need all three. SPF and DKIM on their own authenticate things the user never sees.
SPF: which IPs may send for this domain
SPF is one TXT record at the domain apex, listing authorised senders.
example.com. IN TXT "v=spf1 include:amazonses.com include:_spf.google.com ip4:203.0.113.7 -all"
Read left to right; the first mechanism that matches wins.
-
include:— pull in another domain's SPF record (your ESP, your CRM, Google Workspace). -
ip4:/ip6:— literal addresses or CIDR blocks. -
a,mx— the domain's own A/MX records. -
all— matches everything; the qualifier on it is your default.
The qualifier on all is the only interesting policy decision:
| meaning | receiver behaviour | |
|---|---|---|
-all |
hard fail | reject or heavily penalise |
~all |
soft fail | accept but mark suspicious |
?all |
neutral | no opinion, i.e. pointless |
Use ~all while you are still discovering which systems send as you, then move to -all. Do not stop at ~all forever: once DMARC is at enforcement, -all is what makes an unauthorised relay actually fail rather than merely look odd.
The 10-lookup limit, and how everyone blows it
RFC 7208 §4.6.4 caps SPF evaluation at 10 DNS-querying mechanisms. include, a, mx, ptr, exists and redirect each count — and they count recursively. Exceed it and the result is permerror, which most receivers treat as "SPF did not pass."
ip4: and ip6: cost nothing. That is the escape hatch.
This record looks harmless and is already broken:
"v=spf1 include:_spf.google.com include:amazonses.com include:servers.mcsv.net include:_spf.salesforce.com include:spf.protection.outlook.com ~all"
_spf.google.com alone expands to three further includes. Five vendor includes routinely resolve to 12–15 lookups. The failure is silent: nothing bounces, deliverability just degrades, and it degrades for every message from the domain, not just the vendor that pushed you over.
Fixes, in order of preference:
- Delete includes for services you no longer use. Most SPF records are archaeology.
- Move a vendor onto its own subdomain (see the subdomain section below) so it gets its own SPF record and its own budget of 10.
- Replace an include with the
ip4:ranges it expands to — only for vendors with stable IPs, and accept that you now own keeping it current. Automated "flattening" services do this for you at the cost of a dependency that breaks when a vendor renumbers quietly.
Check the count before you ship a change. dig +short TXT example.com shows the record; a validator shows the expansion.
Why SPF alone is not enough
SPF authenticates the connecting IP against the envelope domain. Two consequences:
-
It says nothing about what the user sees. An attacker publishes perfect SPF for
evil.example, uses it as the envelope sender, and still putsFrom: you@yourbank.comin the headers. SPF passes. That is exactly why DMARC's alignment check exists. - It breaks on forwarding. A user forwards your mail from a university address to Gmail. That server connects from an IP your record does not list while keeping the original envelope sender, so SPF fails — correctly and uselessly. Some forwarders rewrite the envelope (SRS); many do not. This is the practical reason DKIM matters.
DKIM: a signature that travels with the message
DKIM is asymmetric crypto. You hold a private key; the public key lives in DNS. Your outbound server hashes a canonicalised set of headers plus the body, signs it, and adds a DKIM-Signature header:
DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=example.com; s=s1;
h=from:to:subject:date:message-id:mime-version:content-type;
bh=2jUSOH9NhtVGCQWNr9BrIAPreKQjO6Sn7XIkfJVOzv8=;
b=dzdVyOfAKCdLXdJOc9G2q8LoXSlEniSbav+yuU4zGeeruD00lszZVoG4ZHRNiYzR
-
d=— the signing domain. This is what DMARC will compare againstFrom:. -
s=— the selector, which locates the public key. Multiple selectors per domain is normal and is how rotation works. -
h=— the list of headers covered by the signature.fromis mandatory. -
bh=— hash of the body;b=— the signature itself.
The public key is a TXT record at <selector>._domainkey.<domain>:
s1._domainkey.example.com. IN TXT "v=DKIM1; k=rsa; p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAv9tZ0hR2m..."
Practical notes that cost me time:
-
DNS TXT strings max out at 255 characters. A 2048-bit key exceeds that and must be split into multiple quoted strings the resolver concatenates. Most providers handle it; some paste it verbatim and silently produce an invalid key. If DKIM "just doesn't verify," check this first with
dig +short TXT s1._domainkey.example.com. -
1024 vs 2048. RFC 8301 sets 1024 as the floor and 2048 as what signers should use. Use 2048; 1024 is a downgrade with no upside. Ed25519 (
k=ed25519) produces tiny records but verifier support is still uneven — dual-sign if you want it. -
Rotation is the whole reason selectors exist. Publish
s2._domainkeywith the new key, wait for propagation, switch signing tos2, wait past the longest TTL plus anything in retry queues (I give it a week), then deletes1. There is never a window where valid mail cannot be verified. Rotate annually, or immediately if a key may have leaked.
Why DKIM survives forwarding
The signature covers headers and body, not the connection. A forwarder that relays the bytes unchanged relays a still-valid signature. That is why DKIM is the load-bearing mechanism in practice and SPF is the one that keeps failing on mailing lists.
It is not bulletproof. Mailing lists that append a footer or prefix [list] to the subject invalidate the body hash or the signed subject. That is what ARC (RFC 8617) papers over: intermediaries record the authentication result they saw so the final receiver can choose to trust it. You do not implement ARC as a sender — you just need to know it is why some list traffic still gets through.
DMARC: alignment, policy, and reports
DMARC connects the previous two to what the user actually sees. It defines alignment, publishes a policy for failures, and gets you reports.
The spec is now on the IETF Standards Track: RFC 9989, 9990 and 9991 were published on 20 May 2026, obsoleting the 2015 informational RFC 7489. I flag below where that changed the tags you write.
Alignment is the entire point
A message passes DMARC if at least one of the following holds:
- SPF passes and the envelope
MAIL FROMdomain aligns with theFrom:domain, or - DKIM passes and the signature's
d=aligns with theFrom:domain.
"Aligns" has two modes, set per-mechanism:
-
Relaxed (
adkim=r,aspf=r— the default): the organizational domains must match.mail.example.comaligns withexample.com. -
Strict (
adkim=s,aspf=s): the FQDNs must match exactly.mail.example.comdoes not align withexample.com.
Here is the failure that catches almost everyone on a first integration. You send through an ESP: your From: is billing@example.com, but the envelope sender is the ESP's own bounce domain.
MAIL FROM:<0100018e-bounces@eu-west-1.amazonses.com>
From: billing@example.com
SPF passes — for amazonses.com. It does not align with example.com, so it contributes nothing to DMARC. If DKIM also signs with d=amazonses.com, DMARC fails outright and you never see an SMTP error, because p=none means "deliver anyway and tell me." Fix it with a custom MAIL FROM subdomain (bounce.example.com, delegated to the ESP) or — easier and sufficient — DKIM signing with d=example.com.
The record
_dmarc.example.com. IN TXT "v=DMARC1; p=none; rua=mailto:dmarc@example.com; adkim=r; aspf=r; fo=1"
-
p=— policy for the organizational domain:none,quarantine,reject. -
sp=— policy for subdomains. If absent,p=applies to them too. -
np=— new in RFC 9989: policy for non-existent subdomains. Setnp=rejectearly; nothing legitimate sends from a subdomain with no DNS records, so it is free protection againstinvoices.example.com-style spoofing. -
rua=— where aggregate reports go.fo=1— request a failure report when any mechanism fails, not only when all do. -
pct=— deprecated in RFC 9989, replaced byt=y(testing) /t=n(default, enforce). Deployed receivers still honourpct=, so it works during a transition, but write new records witht=.
Rolling out without breaking payroll
The order matters, and the whole point of starting at none is that it is observation-only.
-
p=none. Nothing changes for recipients; you just start receiving reports. Leave it two to four weeks — long enough to catch the monthly invoice run and whatever marketing does. - Fix what the reports show. Every legitimate source must reach alignment on SPF or DKIM. This is where you discover the CRM nobody told you about.
-
p=quarantine, initially sampled (t=y, or the olderpct=25), then full. -
p=reject, once reports show near-100% alignment for a couple of weeks.
Do not jump straight to reject. I have watched a company do that and take down their own recruiting pipeline for two days.
Actually reading an aggregate report
rua reports arrive daily as gzipped XML, one file per reporting receiver. The useful part is the per-source-IP rows:
<record>
<row>
<source_ip>203.0.113.7</source_ip>
<count>412</count>
<policy_evaluated>
<disposition>none</disposition>
<dkim>pass</dkim>
<spf>fail</spf>
</policy_evaluated>
</row>
<identifiers>
<header_from>example.com</header_from>
</identifiers>
<auth_results>
<dkim><domain>example.com</domain><result>pass</result></dkim>
<spf><domain>bounces.esp.example</domain><result>pass</result></spf>
</auth_results>
</record>
Read it in this order:
-
policy_evaluatedis the aligned result;auth_resultsis the raw one. Whenauth_results/spfsayspassbutpolicy_evaluated/spfsaysfail, that is an alignment problem, not an SPF problem — exactly the ESP case above. -
countis messages, not recipients. Sort descending; two or three IPs will be 95% of your volume. - Rows with
dkim=failandspf=failand a meaningful count are either a source you forgot or someone spoofing you. Look up the IP before assuming which.
Do not parse these by hand past week one — point rua at an aggregator and read a dashboard. Understand the XML once so you know what the dashboard is summarising.
The bulk-sender rules you now have to meet
Since 2024 the large mailbox providers have made this mandatory rather than advisory, and enforcement has hardened considerably.
Google and Yahoo apply their requirements to senders of 5,000+ messages per day to their users. Per Google's sender guidelines FAQ, messages from the same primary domain — subdomains included — count toward that 5,000, and once you are classified as a bulk sender the classification does not expire. Requirements: SPF and DKIM both set up, a DMARC record at minimum p=none, and the From: organizational domain aligned with either the SPF or the DKIM organizational domain. Spam complaint rate should stay below 0.1%, and at 0.3% or above you become ineligible for mitigation until you have been back under 0.3% for seven consecutive days. Since November 2025 Gmail has escalated from temporary deferrals to permanent rejections for non-compliant traffic (Proofpoint's write-up).
Microsoft joined on 5 May 2025 with the same 5,000/day threshold for consumer Outlook/Hotmail/Live addresses, requiring SPF, DKIM and DMARC with alignment on at least one (dmarcian's summary). Non-compliant mail is rejected with 550 5.7.515 Access denied, sending domain [domain] does not meet the required authentication level (uriports) — a hard bounce, not a spam-folder problem.
One-click unsubscribe (RFC 8058) applies to marketing and promotional mail, not to transactional mail like password resets or receipts. Both headers are required:
List-Unsubscribe: <https://example.com/u/9f3a2c>, <mailto:unsub@example.com?subject=unsub>
List-Unsubscribe-Post: List-Unsubscribe=One-Click
The URL must accept an unauthenticated POST with body List-Unsubscribe=One-Click and unsubscribe on that single request — no confirmation page, no login. Requests must be honoured within two days. A mailto:-only or plain-link unsubscribe does not satisfy it.
Also expected across all three: TLS on delivery, valid forward-confirmed reverse DNS on sending IPs, and RFC 5322-conformant messages.
These are floors, not targets — meeting them gets you considered, not delivered. And do not wait until 5,000/day: the threshold is where enforcement begins, but the reputation you build below it is what you arrive with.
Split transactional and marketing onto different subdomains
Reputation is tracked per domain (and per IP), and it is dominated by complaint rate. Marketing email gets complaints structurally — people who wanted a discount code six months ago now hit "spam" instead of unsubscribe. Password resets get essentially none. Send both from example.com and the newsletter's complaints degrade the login flow. That is how you end up with users unable to receive a verification code.
mail.example.com -> transactional: receipts, resets, alerts
news.example.com -> marketing: newsletters, campaigns
example.com -> corporate mail (Google Workspace / Microsoft 365)
Each gets its own SPF record — and therefore its own 10-lookup budget — and its own DKIM keys. DMARC still works: with default relaxed alignment, From: noreply@mail.example.com aligns against the policy at _dmarc.example.com, and you can override per-subdomain with a dedicated _dmarc.mail.example.com record. Publish sp= and np= on the parent to cover subdomains you have not delegated.
The trade-off: a fresh subdomain has no reputation and needs warming — low volume, ramped over two to four weeks. Do this before you need it, not during a migration.
Failure modes, with symptoms
Mail delivers, but DMARC reports show spf=fail while raw SPF passes.
Cause: the envelope sender is your ESP's bounce domain, so SPF authenticates the wrong domain. Fix: DKIM-sign with d=yourdomain.com, or set a custom MAIL FROM subdomain.
Deliverability degraded gradually, nothing obviously changed.
Cause: SPF exceeded 10 lookups after someone added a vendor include, yielding permerror. Fix: count the expansion, prune dead includes, move vendors to subdomains.
DKIM never verifies for a newly added selector.
Cause: the 2048-bit key was pasted as a single TXT string over 255 characters, the provider added surrounding quotes, or the p= value carries line breaks. Fix: dig +short TXT s1._domainkey.example.com, reassemble, compare byte-for-byte with the signer's key.
Direct mail is fine, mailing-list traffic fails DMARC.
Cause: the list modified the subject or body, breaking the DKIM signature; SPF was already broken by the relay. Fix: nothing on your side. Expected, and why ARC exists. Do not weaken your policy over it.
550 5.7.515 from Outlook.
Cause: you crossed 5,000/day to consumer Microsoft addresses without full SPF + DKIM + DMARC alignment. Fix: publish DMARC (even p=none) and confirm at least one aligned mechanism.
DMARC passes but mail still goes to spam.
Cause: authentication is identity, not reputation — complaint rate, list hygiene and sending history decide placement. Fix: suppress hard bounces and complaints, honour unsubscribes fast, stop mailing people who have not opened anything in a year.
The afternoon checklist
- Inventory every system that sends as your domain. Billing, CRM, monitoring, the app, HR. This step is longer than you expect.
- Publish one SPF record per sending domain, ending in
~all. Verify the expansion is under 10 lookups. - Enable DKIM on every source. 2048-bit, unique selector per source. Verify each with
dig. - Publish
_dmarcwithp=none; rua=mailto:...; fo=1; np=reject. Pointruaat an aggregator. - Wait two to four weeks. Read reports. Fix alignment until every legitimate source passes.
- Move SPF to
-all. Move DMARC top=quarantine, thenp=reject. - Add
List-Unsubscribe+List-Unsubscribe-Postto marketing mail and make the POST endpoint work unauthenticated. - Split transactional and marketing subdomains; warm the new one.
- Set a calendar reminder to rotate DKIM keys in 12 months. You will not remember otherwise.
Steps 1–4 genuinely fit in an afternoon. Step 5 is the one people skip, and it is the only one that tells you whether the rest worked.
Disclosure, since it is relevant: I build Pulsenote, a transactional email API that handles DKIM signing, bounce/complaint suppression and delivery webhooks so the above is mostly configuration rather than code — free tier is 100 emails/month, no card. Everything in this article applies identically whether you use it, a competitor, or your own Postfix box; the DNS is yours either way.
Top comments (1)
The best part is that it turns email authentication from a confusing checklist into something you can actually reason about and troubleshoot. ~