Guide Work software

SPF, DKIM and DMARC for a small domain: setup and a checker

Gmail, Yahoo and Outlook.com want all three from big senders. The rules, the setup order, and a free script we ran on 20 company domains.

The short read

GitHub's SPF record used 10 DNS lookups on October 30, 2026, the most the SPF standard allows.

  • Gmail, Yahoo and Outlook.com require SPF, DKIM and a DMARC record from bulk senders; Outlook.com's bounce for failing mail is 550 5.7.515.
  • Our checker read 20 company domains on October 30, 2026: all publish DMARC, and 13 still carry pct, a tag RFC 9989 removed.

What you can do with this

Publish one SPF record under 10 lookups, turn on DKIM at 2048 bits, then add DMARC at p=none with a report address. Run the checker after each change to catch a broken record.

A bearded man with glasses typing on a laptop whose screen shows lines of white text in a terminal window.
Photo: Kowalski7cc, CC0
In this article
  1. What the three big mailbox providers require
  2. How the three records fit together
  3. Step 1: Publish one SPF record under 10 lookups
  4. Step 2: Turn on DKIM signing at 2048 bits
  5. Step 3: Add DMARC at p=none, with a report address
  6. Step 4: Move to quarantine and reject slowly, or not at all
  7. Step 5: Check the records with a script
  8. What the checker found on 20 company domains
  9. GitHub sits at the SPF limit and Shopify has a broken key
  10. What the tests on invented records caught
  11. When this setup is the wrong tool
  12. The order to do it in
  13. How we made this

Gmail, Yahoo and Outlook.com now require three DNS records from any domain that sends in bulk: SPF, DKIM and DMARC. Outlook.com’s bounce for it reads “550 5.7.515 Access denied”. Even a small office that never sends a newsletter needs SPF or DKIM to send to Gmail.

The usual failure is a record that exists but is broken: two SPF records, too many lookups, or a DMARC record at the wrong name. Mail still leaves. It just stops landing.

This guide gives the rules from Google, Yahoo and Microsoft, the order to set the records up in, and a Python checker that finds the common breaks. We read every rule on October 30, 2026. We then ran the checker on 20 well-known company domains the same day and tested it on invented records.

What the three big mailbox providers require

The rules split at a volume line. Google’s line is “more than 5,000 messages per day to Gmail accounts”. Microsoft’s is 5,000 or more to its consumer services from one From domain. Yahoo’s sender page has a bulk tier but gives no number for it.

Rule Google, all senders Google, over 5,000 a day Yahoo, bulk Outlook.com, 5,000 or more a day
SPF SPF or DKIM Both SPF and DKIM Both SPF and DKIM SPF must pass
DKIM SPF or DKIM; keys of 1024 bits or longer Both Both DKIM must pass
DMARC Not required Required, p=none is enough Required, at least p=none, “DMARC must pass” Required, at least p=none
Alignment Not stated From domain aligned with SPF or DKIM Same; relaxed alignment is fine Aligned with SPF or DKIM, “preferably both”
Spam rate Below 0.3% in Postmaster Tools Below 0.30% Below 0.3% Not stated as a number
Unsubscribe Not stated One-click for marketing mail One-click header, honored within 2 days Recommended: a visible opt-out link

Sources for the table: Google’s email sender guidelines, Yahoo’s sender best practices, and Microsoft’s announcement of April 2, 2025, with its 5.7.515 help page. Google and Yahoo date their enforcement from February 2024.

Microsoft’s own post says two different things. One passage says “we have made a decision to reject messages that don’t pass”, with the 5.7.515 code, from May 5, 2025. The next says that after May 5 Outlook “will begin routing messages from high volume non-compliant domains to the Junk folder”, with rejection at a “date to be announced”.

Its help page explains the 5.7.515 bounce as a rejection for exactly this reason. Plan for the rejection.

How the three records fit together

Each record answers a different question, and DMARC only works when one of the other two passes for the right domain.

SPF lists the servers allowed to send for a domain. The receiver checks the envelope sender, the hidden MAIL FROM address the bounce goes to, not the From line people see.

DKIM puts a signature in each message; the receiver fetches the public key from DNS at <selector>._domainkey.<domain>. DMARC ties them to the visible From domain. A message passes DMARC when SPF or DKIM passes for a domain that matches the From domain, which the standard calls alignment.

That is why a newsletter tool can pass SPF and still fail DMARC. The tool’s own bounce domain passes SPF, but it does not match your From address. Microsoft’s SPF guide adds a twist for the soft-fail ending ~all. DMARC treats it as a failure, “but the DMARC policy is effectively ignored for SPF ~all failures if the messages don’t also contain DKIM signatures.”

DMARC itself changed in 2026. RFC 9989, a Proposed Standard from May 2026, replaces RFC 7489, the informational version most guides still quote. It removes the pct tag and adds np (policy for subdomains that do not exist), t (test mode) and psd.

Step 1: Publish one SPF record under 10 lookups

Add one TXT record at the domain itself. For a domain that sends only through its mail provider:

Mail provider SPF record Ending the vendor recommends
Google Workspace v=spf1 include:_spf.google.com ~all ~all (soft fail)
Microsoft 365 v=spf1 include:spf.protection.outlook.com -all -all (hard fail)
A domain that sends no mail v=spf1 -all -all

Values from Google’s SPF setup page (updated October 26, 2026) and Microsoft’s SPF guide (updated July 2026). The two vendors disagree on the ending.

Microsoft wants -all because it expects DKIM and DMARC alongside; Google says “Google recommends you use ~all”. With DKIM and DMARC in place, we would follow Microsoft. Without them, -all on a record that misses a sender bounces real mail.

Each other service that sends as your domain (a help desk, a newsletter tool, an invoicing app) adds its own include: from its documentation. Three rules from RFC 7208 then decide whether the record works:

  • One record only. If a domain has more than one v=spf1 record, the check “produces the “permerror” result”. The fix is to merge them, never to add a second.
  • At most 10 lookups. The terms include, a, mx, ptr, exists and redirect each cost one, counted through every nested include. Past 10, the receiver “MUST return “permerror”“. ip4, ip6 and all cost nothing.
  • At most 2 void lookups. A term that finds no record counts as void, and the RFC says implementations “SHOULD limit “void lookups” to two.”

Google’s page states the limit as “up to 10 include: tags”. That is looser than the RFC, because an include that holds three more includes costs four. Microsoft’s guide gives that same example and adds that “having fewer than 10 include: statements doesn’t guarantee fewer than 10 DNS lookups.”

When the count gets close, Microsoft suggests moving a bulk tool to a subdomain such as marketing.example.com, which has its own budget of 10. It warns against replacing its own include with IP addresses, because Microsoft 365’s sending addresses “change frequently.”

Step 2: Turn on DKIM signing at 2048 bits

DKIM is switched on in the mail service, then published in DNS. The order matters in both big suites.

Google Workspace. A super administrator opens Menu, Apps, Google Workspace, Gmail, then Authenticate email, and clicks Generate New Record. Pick 2048 bits unless the DNS host cannot store a key that long; the default selector is google. Add the TXT record it shows, and only then click Start authentication. Google’s DKIM page says it can take up to 48 hours to start working.

Microsoft 365. A custom domain needs two CNAME records, selector1._domainkey and selector2._domainkey, pointing at targets Microsoft generates for your tenant. Once DNS has them, enable signing in the Defender portal under Email authentication settings, DKIM.

In PowerShell, New-DkimSigningConfig takes -KeySize 1024 or 2048, and Microsoft’s DKIM guide lists 1024 as the default. Set 2048 explicitly.

Why 2048: RFC 8301 says signers “MUST use RSA keys of at least 1024 bits” and “SHOULD use RSA keys of at least 2048 bits.” Gmail accepts 1024. The longer key is the standard’s advice, and the only cost is a TXT record too long for some DNS panels.

Every outside service that sends as your domain needs its own DKIM key under its own selector, set up in that service. Without it, the service’s mail can pass DMARC only through an aligned SPF result. RFC 9989 notes that forwarded mail “will most likely fail SPF checks” while DKIM signatures “will generally remain valid”.

Step 3: Add DMARC at p=none, with a report address

Create a TXT record at _dmarc.example.com. Start with:

v=DMARC1; p=none; rua=mailto:dmarc-reports@example.com

p=none asks receivers to change nothing; RFC 9989 calls this monitoring mode. The rua address gets daily aggregate reports from receivers: which servers sent as your domain and whether they passed. Without rua, receivers “MUST NOT generate aggregate feedback reports”, so you learn nothing.

Google’s DMARC page advises waiting 48 hours after SPF and DKIM before adding DMARC. It warns that large organizations “might get up to hundreds or even thousands of reports daily”, so send them to a group or a dedicated mailbox, not a person.

Two more rules from the standard trip people up. The record must start with v=DMARC1; if it does not, “the entire record MUST be ignored.” And if two DMARC records sit at the same name, receivers discard them all. Subdomains with no record of their own inherit the parent’s sp= policy, or p= when there is no sp=.

Step 4: Move to quarantine and reject slowly, or not at all

Once the reports show only your own services, and all of them passing, raise the policy to p=quarantine (failing mail goes to spam), then p=reject.

RFC 9989 gives a pace, and a warning most setup guides leave out. A domain whose people post to mailing lists “SHOULD NOT” publish p=reject, because lists and forwarders change messages in ways that break the checks. The RFC explains that receivers bounce those messages and list software can then unsubscribe the bounced readers. A domain that still wants reject should publish p=none “for at least a month”, then p=quarantine “for an equally long period”, and compare the reports.

The old way to ramp up was pct=25, applying the policy to a quarter of failing mail. RFC 9989 removed the tag because it “was usually not accurately applied, unless the value specified was either 0 or 100”. Google’s DMARC page, updated October 26, 2026, still says “you might start with a small percentage of your messages.”

Under the new standard, t=y is the softer step instead: receivers apply one level less than the policy you publish, so quarantine is handled as none.

Step 5: Check the records with a script

Free online tools can show these records, but a script you can read shows what it counted and can run on a schedule. Ours needs Python 3 and nothing else. It asks Google’s public DNS-over-HTTPS service, so it needs only HTTPS access and no DNS tools.

It counts SPF lookups through every include. It flags duplicate records, more than 10 lookups, more than 2 void lookups, include loops and +all.

It finds the DMARC record, walking up to the parent domain for a subdomain, and notes a missing rua or a leftover pct. It looks for DKIM keys under 16 common selectors and measures each RSA key’s length. A failed DNS query is reported as “lookup failed”, never as a missing record.

#!/usr/bin/env python3
"""Check a domain's SPF, DMARC and DKIM records over DNS-over-HTTPS.

Usage: python3 mailauth.py example.com [more domains] [--json] [--selector NAME ...]
Standard library only. Exit code 1 if any domain has a problem, 2 if a lookup failed.
"""
import base64, json, sys, time, urllib.parse, urllib.request

DOH = "https://dns.google/resolve"
SELECTORS = ["google", "selector1", "selector2", "k1", "k2", "k3", "s1", "s2",
             "default", "dkim", "mail", "smtp", "sig1", "mandrill", "pm", "fm1"]


class LookupFailed(Exception):
    pass


_cache = {}


def doh_txt(name, rtype="TXT"):
    key = (name.lower(), rtype)
    if key not in _cache:
        _cache[key] = _doh(name, rtype)
    return _cache[key]


def _doh(name, rtype):
    """Return a list of record strings, [] for NXDOMAIN or no data. Raise on errors."""
    url = DOH + "?" + urllib.parse.urlencode({"name": name, "type": rtype})
    for attempt in range(3):
        try:
            with urllib.request.urlopen(url, timeout=10) as r:
                data = json.load(r)
            break
        except Exception as e:
            if attempt == 2:
                raise LookupFailed(f"{name} {rtype}: {e}")
            time.sleep(1 + attempt)
    status = data.get("Status")
    if status == 3:          # NXDOMAIN
        return []
    if status != 0:          # SERVFAIL, REFUSED and so on: not the same as "no record"
        raise LookupFailed(f"{name} {rtype}: DNS status {status}")
    want = {"TXT": 16, "MX": 15}[rtype]
    out = []
    for a in data.get("Answer", []):
        if a.get("type") != want:
            continue          # skip CNAME steps on the way to the record
        d = a["data"]
        out.append(join_txt(d) if rtype == "TXT" else d)
    return out


def join_txt(d):
    """Join the quoted strings of one TXT record (each at most 255 bytes) without spaces."""
    if not d.startswith('"'):
        return d
    return "".join(p.strip('"') for p in d.split('" "'))


# ---------- SPF (RFC 7208) ----------

LOOKUP_TERMS = ("include", "a", "mx", "ptr", "exists", "redirect")


def spf_records(domain, txt):
    return [r for r in txt(domain) if r.lower() == "v=spf1" or r.lower().startswith("v=spf1 ")]


def spf_walk(domain, txt, state, depth=0):
    """Count DNS-lookup terms in domain's SPF record and everything it includes."""
    recs = spf_records(domain, txt)
    if not recs:
        # An include or redirect target without an SPF record is a permerror (RFC 7208 5.2, 6.1).
        state["notes"].append(f"{domain}: included but has no SPF record")
        state["permerror"] = True
        if not txt(domain):
            state["void"] += 1    # no TXT data at all counts as a void lookup
        return None
    if len(recs) > 1:
        state["notes"].append(f"{domain}: {len(recs)} SPF records (permerror)")
        state["permerror"] = True
        return None
    terms = recs[0].split()[1:]
    has_all = any(t.lstrip("+-~?").lower() == "all" for t in terms)
    for t in terms:
        q = t[0] if t[0] in "+-~?" else ""
        body = t[len(q):]
        name = body.split(":", 1)[0].split("=", 1)[0].split("/", 1)[0].lower()
        if name not in LOOKUP_TERMS:
            continue
        if name == "redirect" and has_all:
            continue          # RFC 7208 6.1: ignored when "all" is present
        state["lookups"] += 1
        if name in ("include", "redirect"):
            sep = ":" if name == "include" else "="
            target = body.split(sep, 1)[1] if sep in body else ""
            if "%" in target:
                state["notes"].append(f"{domain}: macro in {name} ({target}), not followed")
                continue
            target = target.rstrip(".").lower()
            if target in state["seen"] or depth > 12:
                state["notes"].append(f"{domain}: loop or deep nesting at {target}")
                state["permerror"] = True
                continue
            state["seen"].add(target)
            spf_walk(target, txt, state, depth + 1)
    return terms


def check_spf(domain, txt):
    recs = spf_records(domain, txt)
    res = {"record": recs[0] if len(recs) == 1 else None, "count": len(recs), "problems": []}
    if not recs:
        res["problems"].append("no SPF record")
        return res
    if len(recs) > 1:
        res["problems"].append(f"{len(recs)} SPF records: receivers return permerror")
        return res
    state = {"lookups": 0, "void": 0, "notes": [], "seen": {domain.lower()}, "permerror": False}
    terms = spf_walk(domain, txt, state)
    alls = [t for t in terms if t.lstrip("+-~?").lower() == "all"]
    res["all"] = alls[0] if alls else ("redirect" if any(t.lower().startswith("redirect=") for t in terms) else "none")
    res["lookups"] = state["lookups"]
    res["void"] = state["void"]
    res["notes"] = state["notes"]
    if state["lookups"] > 10:
        res["problems"].append(f"{state['lookups']} DNS lookups, limit is 10: permerror")
    if state["void"] > 2:
        res["problems"].append(f"{state['void']} void lookups, limit is 2: permerror")
    if state["permerror"]:
        res["problems"].append("permerror in an included record")
    if res["all"] in ("+all", "all"):
        res["problems"].append("+all lets any server on the internet pass SPF")
    if res["all"] == "none":
        res["problems"].append("no 'all' or redirect: unlisted senders get a neutral result")
    return res


# ---------- DMARC (RFC 9989) ----------

def dmarc_tags(rec):
    tags = {}
    for part in rec.split(";"):
        if "=" in part:
            k, v = part.split("=", 1)
            tags[k.strip().lower()] = v.strip()
    return tags


def check_dmarc(domain, txt):
    labels = domain.lower().rstrip(".").split(".")
    # The author domain first, then its parents (a simplified DNS tree walk, RFC 9989 4.10).
    for i in range(0, max(1, len(labels) - 1)):
        name = ".".join(labels[i:])
        recs = [r for r in txt("_dmarc." + name) if r.replace(" ", "").lower().startswith("v=dmarc1")]
        if len(recs) > 1:
            return {"found_at": name, "record": None, "problems": [f"{len(recs)} DMARC records at _dmarc.{name}: all are discarded"]}
        if recs:
            break
    else:
        return {"found_at": None, "record": None, "problems": ["no DMARC record"]}
    rec = recs[0]
    tags = dmarc_tags(rec)
    res = {"found_at": name, "record": rec, "p": tags.get("p"), "sp": tags.get("sp"),
           "rua": "rua" in tags, "problems": [], "notes": []}
    if tags.get("p") not in ("none", "quarantine", "reject"):
        res["problems"].append("missing or invalid p= tag" + (" (treated as p=none because rua is set)" if "rua" in tags else ""))
    if not res["rua"]:
        res["notes"].append("no rua: nobody receives aggregate reports")
    if "pct" in tags:
        res["notes"].append(f"pct={tags['pct']}: removed in RFC 9989, receivers may ignore it")
    if tags.get("t", "n").lower() == "y":
        res["notes"].append("t=y: test mode, policy is softened by one step")
    if name != domain.lower():
        res["notes"].append(f"record found at parent {name}; subdomain policy is sp= or p=")
    return res


# ---------- DKIM (RFC 6376, RFC 8301) ----------

def _der_len(b, i):
    n = b[i]; i += 1
    if n < 0x80:
        return n, i
    k = n & 0x7F
    return int.from_bytes(b[i:i + k], "big"), i + k


def rsa_bits(p_b64):
    """Modulus size of an RSA SubjectPublicKeyInfo or bare RSAPublicKey, or None."""
    try:
        b = base64.b64decode(p_b64 + "=" * (-len(p_b64) % 4))
        i = 0
        assert b[i] == 0x30; total, i = _der_len(b, i + 1)
        if i + total > len(b):
            return "truncated"                # the record is cut short of its declared length
        if b[i] == 0x30:                      # SubjectPublicKeyInfo: skip AlgorithmIdentifier
            ln, j = _der_len(b, i + 1); i = j + ln
            assert b[i] == 0x03; _, i = _der_len(b, i + 1); i += 1   # BIT STRING, unused-bits byte
            assert b[i] == 0x30; _, i = _der_len(b, i + 1)
        assert b[i] == 0x02; ln, i = _der_len(b, i + 1)
        if i + ln > len(b):
            return "truncated"
        n = b[i:i + ln].lstrip(b"\x00")
        return n[0].bit_length() + 8 * (len(n) - 1)
    except Exception:
        return None


def check_dkim(domain, txt, selectors):
    found = []
    # A name no one would choose as a selector: if it answers, a wildcard sits at *._domainkey.
    wildcard = txt(f"x-no-such-selector-7f3a._domainkey.{domain}")
    for s in selectors:
        for rec in txt(f"{s}._domainkey.{domain}"):
            if rec in wildcard:
                continue          # the wildcard's answer, not a key published for this selector
            tags = dmarc_tags(rec)
            if "p" not in tags or tags.get("v", "DKIM1") != "DKIM1":
                continue          # not a DKIM key record (for example "v=spf1 -all")
            k = tags.get("k", "rsa").lower()
            p = tags.get("p", "").replace(" ", "")
            bits = None if k != "rsa" or not p else rsa_bits(p)
            found.append({"selector": s, "k": k, "bits": bits, "revoked": p == ""})
    res = {"found": found, "problems": [], "wildcard": wildcard[0] if wildcard else None}
    weak = [f for f in found if isinstance(f["bits"], int) and f["bits"] < 1024]
    if weak:
        res["problems"].append("key under 1024 bits: " + ", ".join(f["selector"] for f in weak))
    cut = [f for f in found if f["bits"] == "truncated"]
    if cut:
        res["problems"].append("key record truncated, cannot verify: " + ", ".join(f["selector"] for f in cut))
    return res


def check_mx(domain):
    mx = doh_txt(domain, "MX")
    return {"mx": len(mx), "null_mx": any(m.strip() in ("0 .", "0") for m in mx)}


def check(domain, txt=doh_txt, selectors=SELECTORS):
    out = {"domain": domain}
    try:
        out["mx"] = check_mx(domain) if txt is doh_txt else {"mx": None, "null_mx": False}
        out["spf"] = check_spf(domain, txt)
        out["dmarc"] = check_dmarc(domain, txt)
        out["dkim"] = check_dkim(domain, txt, selectors)
    except LookupFailed as e:
        out["error"] = str(e)
    return out


def summary(r):
    if "error" in r:
        return f"{r['domain']}: LOOKUP FAILED ({r['error']}), result unknown, not 'missing'"
    s, d, k = r["spf"], r["dmarc"], r["dkim"]
    lines = [f"{r['domain']}  (MX records: {r['mx']['mx']})"]
    lines.append(f"  SPF   {s.get('all', '-'):9} lookups {s.get('lookups', '-')}  void {s.get('void', '-')}")
    lines.append(f"  DMARC p={d.get('p')} sp={d.get('sp')} rua={'yes' if d.get('rua') else 'no'}"
                 + (f"  (at {d['found_at']})" if d.get("found_at") and d["found_at"] != r["domain"] else ""))
    keys = ", ".join(f"{f['selector']}={'revoked' if f['revoked'] else (f['bits'] or f['k'])}" for f in k["found"])
    lines.append(f"  DKIM  {keys or 'no key at the selectors tried (not proof there is none)'}")
    for p in s["problems"] + d["problems"] + k["problems"]:
        lines.append(f"  PROBLEM {p}")
    if k.get("wildcard"):
        lines.append(f"  note  wildcard at *._domainkey answers every selector: {k['wildcard'][:40]}")
    for n in d.get("notes", []):
        lines.append(f"  note  {n}")
    return "\n".join(lines)


def main(argv):
    as_json = "--json" in argv
    sels = list(SELECTORS)
    domains = []
    it = iter(a for a in argv if a != "--json")
    for a in it:
        if a == "--selector":
            name = next(it, None)
            if not name:
                print("--selector needs a name"); return 2
            sels.insert(0, name)
        else:
            domains.append(a.lower().rstrip("."))
    if not domains:
        print(__doc__.strip()); return 2
    results = [check(d, selectors=sels) for d in domains]
    if as_json:
        print(json.dumps(results, indent=1))
    else:
        print("\n\n".join(summary(r) for r in results))
    if any("error" in r for r in results):
        return 2
    return 1 if any(r["spf"]["problems"] or r["dmarc"]["problems"] or r["dkim"]["problems"] for r in results) else 0


if __name__ == "__main__":
    sys.exit(main(sys.argv[1:]))

Save it as mailauth.py and run it on your domain, adding your DKIM selector if you know it:

python3 mailauth.py example.com --selector google
python3 mailauth.py example.com shop.example.com --json > mail-dns.json

It exits with 0 when nothing is wrong, 1 when a domain has a problem and 2 when a lookup failed. A scheduled job can alert on anything other than 0.

Know its limits before you trust a clean result. DKIM selectors cannot be listed in DNS, so “no key at the selectors tried” proves nothing; the selector is in the s= field of a signed message’s DKIM-Signature header.

It counts each mx term as one lookup and does not resolve the address records behind it, so a void a or mx term is not counted. It does not follow includes built from macros such as %{i}, so those counts are a floor. And it checks records, not messages: a real test still needs a message sent to an outside mailbox, with its header read for the SPF, DKIM and DMARC results.

What the checker found on 20 company domains

We ran it on October 30, 2026 at 19:30 UTC, on 20 domains of companies whose software small offices use. The run took 69 seconds. These are DNS records at that moment, not a judgment of anyone’s mail.

Domain SPF ends SPF lookups DMARC p pct tag DKIM keys found (bits)
airtable.com ~all 8 reject 100 4 x 1024
asana.com -all 6 quarantine none 2048, 1024
atlassian.com ~all 6 reject 100 2048, 3 x 1024
canva.com -all 9 reject none 5 x 2048, 2 x 1024
cloudflare.com -all 7 reject 100 2048, 2 x 1024
dropbox.com ~all 7 reject 100 none at our 16 selectors
figma.com -all 4 quarantine none 5 x 2048, 1024
github.com ~all 10 quarantine 100 5 x 2048, 2 x 1024
google.com ~all 1 reject none none at our 16 selectors
hubspot.com redirect 4 reject 100 2048, 3 x 1024
mailchimp.com ~all 7 reject none 2 x 2048, 3 x 1024
microsoft.com -all 6 reject 100 1024
monday.com -all 7 reject none 4 x 2048, 3 x 1024
notion.so ~all 0 quarantine 100 none at our 16 selectors
salesforce.com ~all 4 reject 100 2048, 3 x 1024
shopify.com ~all 4 reject 100 2 x 1024, 1 truncated
slack.com -all 3 reject 100 none at our 16 selectors
stripe.com ~all 7 reject 100 4 x 1024
zapier.com -all 9 reject 100 3 x 2048, 3 x 1024
zoom.us ~all 6 reject none 2048, 4 x 1024

Guess how many of the 20 still run DMARC at p=none. None do: 16 publish p=reject and 4 p=quarantine. All 20 have a DMARC record and 19 have a rua address; figma.com’s record is only v=DMARC1; p=quarantine;, so nobody gets its reports.

GitHub sits at the SPF limit and Shopify has a broken key

GitHub sits exactly at the limit. Its SPF record reaches 10 lookups through eight includes, a Salesforce exists term and a nested SendGrid include. We counted it again by hand from the raw records and got 10. One more vendor include and receivers that walk the whole record must return permerror. Receivers stop at the first match, so mail from senders listed early would still pass.

13 of the 20 still carry pct=100. It does no harm, since 100 was the default, but RFC 9989 no longer defines it.

Shopify publishes a broken key. The record at pm._domainkey.shopify.com declares a 768-bit RSA key and stops after 84 of its 126 bytes; OpenSSL refused to read it. We cannot tell whether anything still signs with that selector.

Short keys are common. Of 71 RSA keys we could measure, 41 were 1024 bits and 30 were 2048. Four domains showed only 1024-bit keys at our selectors: microsoft.com, stripe.com, shopify.com and airtable.com. That passes Gmail’s rule and falls short of the RFC’s “SHOULD”. Some keys under selectors like mandrill or pm may belong to a vendor rather than the company.

notion.so is the odd record: SPF v=spf1 ~all, which authorizes no server, no MX record, and DMARC at quarantine. That suggests a domain not meant to send mail, though the records alone cannot prove it. For such a domain, Microsoft’s guide recommends v=spf1 -all.

What the tests on invented records caught

We wrote 13 tests that feed the checker invented DNS data, one case per rule. They cover two SPF records, 11 lookups through nesting, three includes that point nowhere, an include loop, +all, a parent-domain DMARC record, a record that does not start with v=DMARC1, two DMARC records, a revoked key, a 512-bit key and a truncated one. All 13 pass on the final script.

The live run caught a bug the tests had missed. slack.com, figma.com and hubspot.com answer even a made-up name under _domainkey, with an SPF record such as v=spf1 -all, which points to a wildcard record. Our first version read those answers as revoked DKIM keys.

The script now probes a made-up selector first and ignores any answer that matches it. We added a test for that case.

When this setup is the wrong tool

A newsletter or sales list at volume. These records are required, but they do not handle unsubscribes, bounces or spam complaints. Gmail and Yahoo also require one-click unsubscribe and a spam rate below 0.3%. Use a dedicated sending service on a subdomain, as Microsoft’s SPF guide suggests, and see our piece on email sending limits for scripts for where an ordinary mailbox stops.

A script that sends through Microsoft 365. The records are only half of it; the login method is changing too. Our SMTP AUTH retirement guide covers the replacements. For a merge from Google Sheets, the mail merge guide shows where Gmail’s limits apply.

A domain with no one watching the reports. If a sending service was missed, p=reject tells receivers to refuse its mail. Only the rua reports show which service it was. Do not raise the policy until someone reads them.

The order to do it in

  1. List every service that sends mail as your domain: the mail suite, help desk, invoicing, newsletter, website forms.
  2. Merge their SPF entries into one record at the domain, run the checker, and stay at 10 lookups or fewer.
  3. Turn on DKIM at 2048 bits in each service that offers it, and add --selector with each selector name to your checker run.
  4. Publish v=DMARC1; p=none; rua=mailto: with an address a group reads, 48 hours after steps 2 and 3.
  5. Read a month of reports, fix every source that fails, then move to p=quarantine for another month before deciding on p=reject.
  6. Put the checker in a weekly scheduled job and treat any exit code other than 0 as a ticket.

How we made this

On October 30, 2026 we read the Google, Yahoo and Microsoft pages and the three RFCs listed below. We wrote the checker, ran 13 tests on invented DNS records, and ran it on 20 public domains through Google’s DNS-over-HTTPS service at 19:30 UTC that day. We recounted GitHub’s SPF lookups by hand and checked two keys with OpenSSL. We sent no mail, tested no delivery to Gmail, Yahoo or Outlook.com, and set up DKIM in no Workspace or Microsoft 365 tenant, so those steps come from the vendors’ documentation.

Sources