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:

  1. 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.
  2. 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.
  3. 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.
  4. Fix every legitimate stream that fails alignment. RFC 9989 says you must fix these before you enforce.
  5. Move to p=quarantine, then p=reject. Add sp if subdomains need a different policy, and keep rua so 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.