DEV Community

SPF permerror: the two failures that look exactly like a working record

Your SPF record can be syntactically perfect, published on the right host, and still fail every check a receiving mail server runs. I have watched it take down three sending domains at once. The record looked correct in the registrar dashboard, it looked correct in three online validators, and mail was being refused anyway.

Here are the two failures behind almost every "but the record is right there" case I have investigated, and how to actually detect them.

1. The ten-lookup limit, which nothing warns you about

RFC 7208 caps SPF evaluation at ten DNS lookups. Every include, a, mx, ptr, exists and redirect mechanism costs one, and includes nest, so the cost of your record is the cost of the whole tree it pulls in.

Cross the limit and the result is permerror. That is not a soft failure or a partial pass. It means the receiving system could not evaluate SPF at all, and most receivers treat that the same way they treat an outright fail.

This is a nasty bug class for three reasons:

  • It is invisible in the record. Your TXT record is 120 characters and looks fine. The eleventh lookup is three levels down inside somebody else's include.
  • It appears without you touching anything. A provider adds an include to their own record, your tree grows, and a record that worked for a year starts failing on a Tuesday.
  • It gets worse as you add vendors. One provider is usually fine. The third one is where teams land.

Counting properly means walking the tree, not counting the includes you can see:

import dns.resolver

MECHANISMS = ("include:", "a:", "mx:", "ptr:", "exists:", "redirect=")

def spf_of(domain):
    for r in dns.resolver.resolve(domain, "TXT"):
        txt = b"".join(r.strings).decode()
        if txt.lower().startswith("v=spf1"):
            return txt
    return None

def count(domain, seen=None, depth=0):
    """Return the number of DNS lookups an SPF evaluation costs."""
    if seen is None:
        seen = set()
    if domain in seen or depth > 10:
        return 0
    seen.add(domain)
    record = spf_of(domain)
    if not record:
        return 0
    total = 0
    for term in record.split():
        low = term.lower()
        for m in MECHANISMS:
            if low.startswith(m) or low in ("a", "mx", "ptr"):
                total += 1                      # this mechanism costs one
                target = term.split(":", 1)[-1].split("=")[-1]
                if low.startswith(("include:", "redirect=")):
                    total += count(target, seen, depth + 1)
                break
    return total

print(count("example.com"))   # 11 or more means permerror
Enter fullscreen mode Exit fullscreen mode

Two things to note. ptr is one lookup and you should not be using it at all. And the seen set matters, because providers do occasionally publish records that point at each other.

The fix is flattening or consolidation, not "add one more include". If you must flatten, automate the re-flattening, because you have now taken on the job of noticing when your provider changes their IP ranges.

2. Two SPF records, which is an instant fail

This one is simpler and even more common. Publishing two TXT records that both start v=spf1 on the same host is a permerror by definition. RFC 7208 is explicit: more than one record means the check terminates.

It happens because each record arrives from a different place and each is individually correct. The web host gives you one during setup. The mail provider gives you another. Neither knows about the other, and the registrar interface happily shows both.

Detection is one query, and the trap is that you have to count records rather than read the first one:

dig +short TXT example.com | grep -ci "^\"v=spf1"
# anything other than 1 is a problem
Enter fullscreen mode Exit fullscreen mode

Note the -c. A checker that reads the first matching record and validates it will tell you everything is fine. We had exactly that bug in our own tooling, which is a large part of why I am writing this: our checker took the first v=spf1 it found and never counted the rest, and never counted lookups either. Both are now counted, and a sweep of 274 domains after the fix came back clean.

The fix is a merge, not a delete. One record, one v=spf1, every include inside it, ending in ~all:

v=spf1 include:_spf.google.com include:spf.protection.outlook.com ~all
Enter fullscreen mode Exit fullscreen mode

Use ~all rather than -all on a sending domain. A soft fail leaves room for forwarding paths that legitimately break SPF, and receivers weight DMARC alignment more heavily anyway.

A third one, if you are on Microsoft 365

Slightly different shape, same class of problem: the record exists, looks right, and does nothing.

Microsoft 365 DKIM uses two CNAMEs, on selector1._domainkey and selector2._domainkey, pointing at a host inside your tenant's onmicrosoft.com domain. Almost every guide prints that target as a fixed pattern. It is not fixed. The host contains a per-tenant value, so a CNAME copied from a blog post, or from a tenant you configured last month, resolves to nothing at all.

The symptom is the worst kind: no error anywhere. The records are published, the portal shows them, and DKIM simply never signs.

Read the target from the tenant you are actually configuring, then confirm the provider reports signing rather than record present. Those are different states, and only one of them puts a d= header on your mail.

Verify against a resolver, not a dashboard

Everything above shares one root cause: the registrar dashboard shows what you saved, and the mail provider sees what a public resolver serves. Those are different things, and the gap between them is where silent failures live. A zone that did not reload. A record saved on the wrong host. A CNAME flattened by a proxy. An apex record the registrar quietly rewrote.

So treat a record as unverified until a resolver outside your own account returns it:

dig TXT example.com @1.1.1.1
dig TXT selector1._domainkey.example.com @1.1.1.1
dig TXT _dmarc.example.com @1.1.1.1
Enter fullscreen mode Exit fullscreen mode

Then send one real message to a mailbox at a different provider and read the Authentication-Results header. A test tool proves the record. A real message proves the path.


I write about this because it is the layer underneath cold email, and it is the layer that decides whether anything else you do matters. If you want the longer version, with DMARC alignment and the order to write the records in, it is here: SPF, DKIM and DMARC for cold email. There is also a free checker that counts lookups and duplicate records rather than reading the first one it finds: emailcampaign.ai/tools/dns-checker.

Disclosure: I build emailcampaign.ai, which provisions and authenticates sending infrastructure. The bug in our own checker described above was real and is fixed.

Top comments (0)