DEV Community

Cover image for SPF, DKIM and DMARC, explained by someone who had to make it work
Greg Pabijan-Morawski
Greg Pabijan-Morawski

Posted on

SPF, DKIM and DMARC, explained by someone who had to make it work

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:

Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

_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:

  1. Delete includes for services you no longer use. Most SPF records are archaeology.
  2. 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.
  3. 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 puts From: you@yourbank.com in 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:

Enter fullscreen mode Exit fullscreen mode
  • d= — the signing domain. This is what DMARC will compare against From:.
  • 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. from is 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..."
Enter fullscreen mode Exit fullscreen mode

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._domainkey with the new key, wait for propagation, switch signing to s2, wait past the longest TTL plus anything in retry queues (I give it a week), then delete s1. 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 FROM domain aligns with the From: domain, or
  • DKIM passes and the signature's d= aligns with the From: domain.

"Aligns" has two modes, set per-mechanism:

  • Relaxed (adkim=r, aspf=r — the default): the organizational domains must match. mail.example.com aligns with example.com.
  • Strict (adkim=s, aspf=s): the FQDNs must match exactly. mail.example.com does not align with example.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.

Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode
  • 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. Set np=reject early; nothing legitimate sends from a subdomain with no DNS records, so it is free protection against invoices.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 by t=y (testing) / t=n (default, enforce). Deployed receivers still honour pct=, so it works during a transition, but write new records with t=.

Rolling out without breaking payroll

The order matters, and the whole point of starting at none is that it is observation-only.

  1. 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.
  2. 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.
  3. p=quarantine, initially sampled (t=y, or the older pct=25), then full.
  4. 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>
Enter fullscreen mode Exit fullscreen mode

Read it in this order:

  1. policy_evaluated is the aligned result; auth_results is the raw one. When auth_results/spf says pass but policy_evaluated/spf says fail, that is an alignment problem, not an SPF problem — exactly the ESP case above.
  2. count is messages, not recipients. Sort descending; two or three IPs will be 95% of your volume.
  3. Rows with dkim=fail and spf=fail and 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:

Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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

  1. Inventory every system that sends as your domain. Billing, CRM, monitoring, the app, HR. This step is longer than you expect.
  2. Publish one SPF record per sending domain, ending in ~all. Verify the expansion is under 10 lookups.
  3. Enable DKIM on every source. 2048-bit, unique selector per source. Verify each with dig.
  4. Publish _dmarc with p=none; rua=mailto:...; fo=1; np=reject. Point rua at an aggregator.
  5. Wait two to four weeks. Read reports. Fix alignment until every legitimate source passes.
  6. Move SPF to -all. Move DMARC to p=quarantine, then p=reject.
  7. Add List-Unsubscribe + List-Unsubscribe-Post to marketing mail and make the POST endpoint work unauthenticated.
  8. Split transactional and marketing subdomains; warm the new one.
  9. 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)

Collapse
 
morphoices profile image
MORPHOICΞS.

The best part is that it turns email authentication from a confusing checklist into something you can actually reason about and troubleshoot. ~