A domain that sends mail through a provider needs three things in DNS: one SPF record that authorizes the provider’s servers within ten DNS lookups, a DKIM public key for each service that signs as you, and a DMARC record that says what receivers should do when neither lines up with the visible From address. That last part, alignment, is where setups that “pass SPF and DKIM” still fail. If your domain sends no mail at all, you want the non-sending lockdown instead.
What each record checks
| Record | Published at | Identity it checks | What DMARC needs from it |
|---|---|---|---|
| SPF | The envelope-sender (MAIL FROM) domain, as TXT | The connecting IP address against that domain’s list | A pass for a MAIL FROM domain aligned with the From domain |
| DKIM | <selector>._domainkey.<d= domain>, as TXT or a CNAME to one |
A signature made with the private key for that d= domain |
A valid signature whose d= is aligned with the From domain |
| DMARC | _dmarc.<From domain>, as TXT |
Neither by itself; it joins the two results to the From header | The policy, the reporting address, and the alignment mode |
The From address is the one recipients see and the one DMARC protects. SPF never looks at it, and DKIM verifies signatures from any domain. Alignment is the bridge: one aligned pass, from SPF or DKIM, is enough.
SPF: one record and ten lookups
Each provider documents an include: for its servers:
| Provider | Where the SPF record goes | What the provider documents |
|---|---|---|
| Google Workspace | Your domain | v=spf1 include:_spf.google.com ~all; Google recommends ~all |
| Microsoft 365 | Your domain | v=spf1 include:spf.protection.outlook.com -all; Microsoft recommends -all |
| Amazon SES | A custom MAIL FROM subdomain, such as bounce.example.com |
v=spf1 include:amazonses.com ~all plus one MX record pointing at feedback-smtp.<region>.amazonses.com |
Amazon SES differs: by default its MAIL FROM domain is a subdomain of amazonses.com, so SPF passes for Amazon, not for you. For an SPF result that can align, configure a custom MAIL FROM subdomain and publish both records there.
If you use two providers, combine them into a single record, such as v=spf1 include:_spf.google.com include:amazonses.com ~all. RFC 7208 allows only one v=spf1 record per name; a second one makes the result permerror.
Live capture, 4 October 2026 (17:52 UTC). SPF records of four large senders, through 1.1.1.1:
$ dig @1.1.1.1 +nocmd google.com TXT +noall +answer | grep v=spf1
google.com. 250 IN TXT "v=spf1 include:_spf.google.com ~all"
$ dig @1.1.1.1 +nocmd microsoft.com TXT +noall +answer | grep v=spf1
microsoft.com. 3268 IN TXT "v=spf1 include:_spf-a.microsoft.com include:_spf-b.microsoft.com include:_spf-c.microsoft.com include:_spf-ssg-a.msft.net include:_spf1-meo.microsoft.com -all"
$ dig @1.1.1.1 +nocmd cloudflare.com TXT +noall +answer | grep v=spf1
cloudflare.com. 300 IN TXT "v=spf1 ip4:199.15.212.0/22 ip4:173.245.48.0/20 include:_spf.google.com include:spf1.mcsv.net include:spf.mandrillapp.com include:mail.zendesk.com include:stspg-customer.com include:_spf.salesforce.com -all"
$ dig @1.1.1.1 +nocmd github.com TXT +noall +answer | grep v=spf1
github.com. 32 IN TXT "v=spf1 ip4:192.30.252.0/22 include:spf.protection.outlook.com include:_netblocks.google.com include:_netblocks2.google.com include:mail.zendesk.com include:_spf.salesforce.com include:servers.mcsv.net include:mktomail.com include:sendgrid.net ip4:62.253.2" "27.114 ip4:166.78.69.169 ip4:166.78.69.170 ip4:166.78.71.131 ~all"
Large senders use ~all and -all alike. GitHub’s record is two quoted strings that split an address in half, which is correct: RFC 7208 joins the strings without spaces, so it reads ip4:62.253.227.114. One string holds at most 255 bytes, so long records must be split.
What counts toward the limit
RFC 7208 §4.6.4 caps the terms that cause DNS queries at 10 per evaluation: include, a, mx, ptr, exists, and the redirect modifier. Nested terms inside an include count too. ip4, ip6, and all are free. Each mx may also trigger at most 10 address lookups of its own, and receivers should give up after two “void” lookups that return nothing. Exceed the limit and the result is permerror.
To count without guessing, we used a short script that follows includes through 1.1.1.1. It doesn’t expand macros, count MX address lookups, or track void lookups:
#!/usr/bin/env python3
"""Walk an SPF record and count the DNS-querying terms RFC 7208 4.6.4 limits to 10."""
import re, subprocess, sys
def spf(name):
out = subprocess.run(["dig", "@1.1.1.1", "+short", name, "TXT"], capture_output=True, text=True).stdout
recs = []
for line in out.splitlines():
joined = "".join(re.findall(r'"((?:[^"\\]|\\.)*)"', line))
if joined.lower().startswith("v=spf1 ") or joined.lower() == "v=spf1":
recs.append(joined)
return recs
total = 0
def walk(name, depth):
global total
recs = spf(name)
if len(recs) != 1:
print(" " * depth + f"{name}: {len(recs)} SPF records")
return
for term in recs[0].split()[1:]:
t = term.lstrip("+-~?").lower()
mech = re.split(r"[:=/]", t, maxsplit=1)[0]
if mech in ("include", "a", "mx", "ptr", "exists", "redirect"):
total += 1
print(" " * depth + f"[{total:2}] {term}")
if mech in ("include", "redirect"):
walk(term.split(":", 1)[1] if mech == "include" else term.split("=", 1)[1], depth + 1)
name = sys.argv[1]
print(f"{name}")
walk(name, 1)
print(f"DNS-querying terms: {total} (limit 10)")
Live capture, 4 October 2026 (17:52 UTC). cloudflare.com’s record walked, then the provider includes from the table above:
$ python3 spfwalk.py cloudflare.com
cloudflare.com
[ 1] include:_spf.google.com
[ 2] include:spf1.mcsv.net
[ 3] include:spf.mandrillapp.com
[ 4] include:mail.zendesk.com
[ 5] include:stspg-customer.com
[ 6] include:_spf.salesforce.com
[ 7] exists:%{i}._spf.mta.salesforce.com
DNS-querying terms: 7 (limit 10)
$ python3 spfwalk.py _spf.google.com
_spf.google.com
DNS-querying terms: 0 (limit 10)
$ python3 spfwalk.py spf.protection.outlook.com
spf.protection.outlook.com
DNS-querying terms: 0 (limit 10)
$ python3 spfwalk.py amazonses.com
amazonses.com
DNS-querying terms: 0 (limit 10)
Six includes cost seven terms because the Salesforce include adds an exists. Each provider include cost one term and nothing beneath it when we looked; _spf.google.com and spf.protection.outlook.com held only address ranges. Walk your record again whenever you add a service. As Microsoft points out, evaluation stops at the first match, so a record over the limit can pass for some senders and fail for others.
If you run several domains with the same senders, redirect=_spf.example.com lets them share one record. RFC 7208 ignores redirect whenever the record also contains all.
Google recommends ~all; Microsoft recommends -all. RFC 9989 §7.1 warns that some receivers reject on an SPF hard fail before DMARC runs, blocking forwarded mail that still carries a valid aligned DKIM signature and keeping it out of your reports.
DKIM: selectors, keys, and rotation
A DKIM signature names a selector (s=) and a domain (d=). The receiver fetches the public key from <selector>._domainkey.<domain>. Selectors let you publish several keys at once, one per service or per rotation step.
| Provider | Records you publish | Key size | Who holds and rotates the key |
|---|---|---|---|
| Google Workspace | TXT at google._domainkey (the default selector) with the key from the Admin console |
Choose 2048-bit if your DNS provider supports it | You: generate a new key and publish it |
| Microsoft 365 | CNAMEs at selector1._domainkey and selector2._domainkey |
1024-bit default in PowerShell; 2048 can be chosen | Microsoft hosts it; you start a rotation |
| Amazon SES Easy DKIM | Three CNAMEs, <token>._domainkey to <token>.<SigningHostedZone> |
2048-bit default; 1024 optional | Amazon generates and hosts it |
Since May 2025, new Microsoft 365 custom domains point at selector1-<domain-with-dashes>._domainkey.<tenant>.<letter>-v1.dkim.mail.microsoft, while older domains keep the *.onmicrosoft.com form. Copy the values from the Defender portal or Get-DkimSigningConfig. A rotation takes four days before the new key signs.
Live capture, 4 October 2026 (17:53 UTC). Google’s and Microsoft’s documented default selector names, on github.com. dkimbits.sh extracts p= and asks OpenSSL 3.6.3 for the key length (key text trimmed):
#!/bin/sh
# Print the RSA key size published at a DKIM selector name.
p=$(dig @1.1.1.1 +short "$1" TXT | tr -d '" \n' | sed 's/.*p=//; s/;.*//')
printf -- '-----BEGIN PUBLIC KEY-----\n%s\n-----END PUBLIC KEY-----\n' "$(echo "$p" | fold -w 64)" \
| /opt/homebrew/bin/openssl pkey -pubin -noout -text 2>&1 | head -1
$ dig @1.1.1.1 +nocmd google._domainkey.github.com TXT +noall +answer
google._domainkey.github.com. 3600 IN TXT "v=DKIM1; k=rsa; p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAj6T5sl/RwdSq ...
...
$ ./dkimbits.sh google._domainkey.github.com
Public-Key: (2048 bit)
$ dig @1.1.1.1 +nocmd selector1._domainkey.github.com TXT +noall +answer
selector1._domainkey.github.com. 3600 IN CNAME selector1-github-com._domainkey.microsoft.onmicrosoft.com.
selector1-github-com._domainkey.microsoft.onmicrosoft.com. 3600 IN TXT "v=DKIM1; k=rsa; p=...
...
Both publication styles side by side. The Google selector is a TXT record in github.com’s own zone, and its 2048-bit key comes as two quoted strings because it is longer than 255 bytes; Google warns that some DNS providers limit TXT length, and a truncated key fails every signature. The Microsoft selector is a CNAME into Microsoft’s zone, so Microsoft can replace the key without an edit to github.com.
If you hold the key yourself, as with Google Workspace or a self-hosted signer, RFC 8301 says signers should use RSA keys of at least 2048 bits and must use at least 1024. M3AAWG’s rotation practice is to rotate at least every six months. Publish the new selector first, switch signing, keep the old key for 7 to 30 days for mail still in transit, then retire it with an empty p= and never reuse the selector name. Date-based selector names such as s202610 make that easy to follow.
You can find a sender’s selector without asking: open a received message’s headers and read s= and d= in its DKIM-Signature.
Alignment in a message header
This header block is constructed, with the shape you get when a provider sends on your behalf:
Return-Path: <bounces+u7k2@em.example.net>
From: Example Shop <orders@example.com>
DKIM-Signature: v=1; a=rsa-sha256; d=example.com; s=s202610; ...
DKIM-Signature: v=1; a=rsa-sha256; d=example.net; s=esp1; ...
Authentication-Results: mx.example.org;
spf=pass smtp.mailfrom=em.example.net;
dkim=pass header.d=example.com header.s=s202610;
dkim=pass header.d=example.net header.s=esp1;
dmarc=pass header.from=example.com
The From domain is example.com. SPF passed for em.example.net, the provider’s bounce domain, which doesn’t align; neither does the example.net signature. Only the d=example.com signature, made with the key you published, aligns, and that one pass is enough. Remove it and the receiver still sees spf=pass and dkim=pass, yet dmarc=fail.
Relaxed alignment, the default (adkim=r, aspf=r), accepts any domain under the same organizational domain, so bounce.example.com aligns with example.com. Strict (s) needs an exact match. RFC 9989 now finds the organizational domain with a DNS tree walk instead of the Public Suffix List, and notes that strict alignment avoids any disagreement between old and new receivers.
DMARC tags under RFC 9989
RFC 9989 replaced RFC 7489 in May 2026, and RFC 9990 now defines aggregate reports. The tag set changed:
| Tag | Meaning | Default |
|---|---|---|
v=DMARC1 |
Required, must come first | none |
p |
Policy for the domain: none, quarantine, reject |
treated as none if missing |
sp |
Policy for existing subdomains | falls back to p |
np |
Policy for subdomains that return NXDOMAIN (new) | falls back to sp, then p |
rua / ruf |
Where aggregate and failure reports go | no reports |
fo |
Failure-report options, used only with ruf |
0 |
adkim / aspf |
DKIM and SPF alignment, r or s |
r |
t |
Test mode, y or n (new) |
n |
psd |
Public-suffix flag, for registries (new) | u |
pct, rf, and ri are now historic, and unknown tags must be ignored.
Live capture, 4 October 2026 (17:52 UTC). The DMARC records behind the SPF records above:
$ dig @1.1.1.1 +nocmd _dmarc.google.com TXT +noall +answer
_dmarc.google.com. 300 IN TXT "v=DMARC1; p=reject; rua=mailto:mailauth-reports@google.com"
$ dig @1.1.1.1 +nocmd _dmarc.microsoft.com TXT +noall +answer
_dmarc.microsoft.com. 1675 IN TXT "v=DMARC1; p=reject; pct=100; rua=mailto:itex-rua@microsoft.com; ruf=mailto:itex-ruf@microsoft.com; fo=1"
$ dig @1.1.1.1 +nocmd _dmarc.cloudflare.com TXT +noall +answer
_dmarc.cloudflare.com. 300 IN TXT "v=DMARC1; p=reject; sp=reject; adkim=r; aspf=r; pct=100; rua=mailto:a1c47f179bc04efd8ee4dcd4d85dfc65@dmarc-reports.cloudflare.net,mailto:rua@cloudflare.com"
$ dig @1.1.1.1 +nocmd _dmarc.github.com TXT +noall +answer
_dmarc.github.com. 17 IN TXT "v=DMARC1; p=quarantine; sp=reject; pct=100; rua=mailto:dmarc@github.com; ruf=mailto:dmarc@github.com; fo=1"
Three of the four still carry pct=100, which is harmless: 100 was the default, and RFC 9989 receivers ignore the tag. Google pairs p=reject with a soft-fail SPF record; the two settings are independent. GitHub sets a stricter policy for subdomains (sp=reject) than for its main domain.
Rolling out: none, quarantine, reject
RFC 9989 describes this sequence, and warns that it can take many months of reports depending on how often you send:
- List every sender: mailbox provider, transactional mail, newsletter tool, help desk, invoicing. Each needs aligned DKIM, and aligned SPF where it supports a custom MAIL FROM.
- Publish monitoring mode.
v=DMARC1; p=none; rua=mailto:dmarc@example.com. If the mailbox is on another domain, that domain must publish an authorization record, as RFC 9990 requires; the non-sending guide shows the format. - Read the aggregate reports. Each receiver sends XML daily or more often; each row gives a
source_ip, a count,header_from, the SPF and DKIM domains and results, and the DMARC outcome. Use a parser or a reporting service. - Fix every legitimate stream that fails alignment. RFC 9989 says you must fix these before you enforce.
- Move to
p=quarantine, thenp=reject. Addspif subdomains need a different policy, and keepruaso you keep seeing new problems.
RFC 9989’s t=y asks receivers to apply one level less than p, so p=reject; t=y acts like quarantine. Be careful with it for now: a receiver still on RFC 7489 code ignores t as unknown and applies p as written, just as RFC 9989 receivers ignore an old pct=25.
Our own records as a worked example
Live capture, 4 October 2026 (17:52 UTC). and.guide’s SPF and DMARC records, our apex addresses, and the Pages project hostname, through 1.1.1.1. The second TXT record at the apex is a verification token and is filtered out:
$ dig @1.1.1.1 +nocmd and.guide TXT +noall +answer | grep -c IN
2
$ dig @1.1.1.1 +nocmd and.guide TXT +noall +answer | grep v=spf1
and.guide. 300 IN TXT "v=spf1 +a +mx -all"
$ dig @1.1.1.1 +nocmd _dmarc.and.guide TXT +noall +answer
_dmarc.and.guide. 300 IN TXT "v=DMARC1;p=quarantine;rua=mailto:admin@and.guide"
$ dig @1.1.1.1 +nocmd and.guide A +noall +answer
and.guide. 300 IN A 172.66.44.244
and.guide. 300 IN A 172.66.47.12
$ dig @1.1.1.1 +nocmd and-guide.pages.dev A +noall +answer
and-guide.pages.dev. 300 IN A 172.66.44.244
and-guide.pages.dev. 300 IN A 172.66.47.12
$ curl -s https://www.cloudflare.com/ips-v4 | grep "^172\."
172.64.0.0/13
$ python3 spfwalk.py and.guide
and.guide
[ 1] +a
[ 2] +mx
DNS-querying terms: 2 (limit 10)
The record costs two terms, plus address lookups for our one MX host. +mx authorizes the mail server, as intended. +a authorizes whatever the apex resolves to, and that is our website: the same two addresses as and-guide.pages.dev, inside a range Cloudflare publishes as its own. Cloudflare documents those ranges as shared by all proxied hostnames, so in SPF terms, +a vouches for addresses that serve many sites besides ours. Whether mail can ever leave from Cloudflare’s edge addresses is up to Cloudflare, not our record.
The lesson: the a mechanism follows your web hosting. An a mx record is accurate only while the apex points at a machine that sends your mail, so after a move to a CDN or a platform like Cloudflare Pages, check what a now authorizes.
The DMARC record has no sp= (or np=), so subdomains get p, which is quarantine. Reports go to a mailbox on the same domain, so no authorization record is needed.
Gmail and Yahoo bulk-sender requirements
Google defines a bulk sender as one sending close to 5,000 or more messages to personal Gmail accounts in 24 hours, counted across the primary domain; the classification is permanent.
| Requirement | Gmail, all senders | Gmail, bulk | Yahoo, all senders | Yahoo, bulk |
|---|---|---|---|---|
| Authentication | SPF or DKIM | SPF and DKIM | SPF or DKIM | SPF and DKIM |
| DMARC | Not listed | A record, p=none allowed; From aligned with SPF or DKIM |
Not listed | At least p=none, and DMARC must pass with alignment |
| Forward and reverse DNS | Required | Required | Required | Required |
| TLS | Required | Required | Not stated | Not stated |
| Spam rate | Below 0.3% in Postmaster Tools | Below 0.3% | Below 0.3% | Below 0.3% |
| Unsubscribe | Not listed | One-click (RFC 8058) plus a visible link, for marketing and subscribed mail | Not listed | One-click List-Unsubscribe; honor within 2 days |
Google’s FAQ says that starting November 2025, Gmail is ramping up enforcement, with temporary and permanent rejections for mail that doesn’t comply. It also says alignment isn’t required for forwarded or mailing-list messages.
Troubleshooting map
| Symptom | Likely cause | Check |
|---|---|---|
Gmail bounce 550 5.7.26 citing the domain’s DMARC policy |
No aligned pass: the provider signs with its own d= and uses its own bounce domain |
Compare header.d and smtp.mailfrom in Authentication-Results with the From domain |
| Report row with SPF pass and DKIM pass but DMARC fail | Authenticated domains not aligned with header_from |
Read the row’s DKIM and SPF domain fields; publish the provider’s DKIM key or set a custom MAIL FROM |
SPF permerror, or a bounce about too many lookups |
More than 10 DNS-querying terms, or two v=spf1 records |
Walk the includes; dig +short example.com TXT | grep -c v=spf1 should print 1 |
dkim=fail with no key found |
Selector record missing, or the DNS panel appended the zone twice (s1._domainkey.example.com.example.com) |
dig s1._domainkey.example.com TXT, using s= from the header |
dkim=fail with a key present |
Key cut off at 255 characters, or a forwarder or list edited the message | Check the key length with OpenSSL; look for a forwarder’s source_ip in reports |
Gmail 4.7.40 or 5.7.40 |
No DMARC record, or one without p= |
dig _dmarc.example.com TXT |
Gmail 4.7.23 or 5.7.25 |
Sending IP has no PTR, or the PTR name doesn’t resolve back | dig -x <ip>; on a shared provider, ask them |
| Subdomain mail quarantined unexpectedly | No sp=, so the subdomain inherits p |
Give the subdomain its own _dmarc record or set sp |
After any change, compare answers from two resolvers in the DNS lookup tool. A record you just fixed can stay cached elsewhere for up to its old TTL.