Guides

Two SPF records, ten lookups, and why flattening rots

Every way an apex SPF record fails: the second v=spf1, the eleventh lookup, the two void lookups nobody counts, and why the qualifier everyone argues about matters less than you think.

Yowl never asks you to touch your apex SPF record, so none of the four failures below can be caused by connecting us. Our sending identity lives on a send. subdomain — additive, on a label nothing is using — which means your apex is not merely handled carefully, it is not in the code path at all.

Your SPF record is a list you wrote from memory

That is the real problem with SPF, and no amount of care with the syntax touches it. You are maintaining a list of everyone allowed to send as your domain, by recollection, years after some of them were added — and the two worst failures on this page (a vendor you stopped using, an eleventh lookup nobody counted) are both “the list drifted from reality and nothing told you”.

Yowl closes that loop from the other end. Your DMARC reports come back to an address on your own domain, which Yowl already hosts, so it reads them for you and turns them into the list you actually wanted — everyone who really did send as you, with the IP, whether the signature aligned, and when they were last seen:

{ "days": 30, "reports": 3, "messages": 6, "passing": 6, "failing": 0,
  "senders": [ { "sourceIp": "54.240.3.24", "signer": "example.com",
                 "messages": 2, "passing": 2, "failing": 0,
                 "lastSeen": "2026-09-19T23:59:59Z" } ] }

Ask your agent “who is sending as us?” and it reads that and names them. A passing source that is not us is almost always a legitimate sender somebody forgot about — the invoicing tool, the CRM, the form on the marketing site — and naming it is the single most useful thing anyone can do for your SPF record, because that is the include: you were about to leave out.

Two honesties built into the answer, because a confident wrong number here is worse than no number. A clean window is evidence, not proof: reports only cover mail that reached a provider that sends them. And no report having ever arrived means the question cannot be answered yet — never that nobody is spoofing you.

The four ways an apex record stops working

If you are maintaining one yourself, these are the four. SPF is a TXT record listing which hosts may send mail for a domain. The receiving server compares the connecting IP against that list. Which record it reads is a separate question, and the more interesting one — SPF checks the envelope, not the From header. This page is about the record itself.

@   TXT   "v=spf1 include:_spf.google.com ip4:203.0.113.10 ~all"

v=spf1 first, then mechanisms in order, then exactly one all with a qualifier. ip4: and ip6: are addresses or CIDR ranges, include: evaluates another domain’s record as part of yours, a and mx match the domain’s own hosts. Leave ptr alone — RFC 7208 discourages it and it is expensive. +all authorises the entire internet to send as you.

A second record breaks the first one

Each provider’s setup page says “add this TXT record”, and after the second one you have two strings starting v=spf1. RFC 7208 §3.2 allows exactly one, and two is a PermError.

Be precise about what that means, because most write-ups aren’t: the check does not fail, it cannot complete. spf=permerror is not spf=fail. The RFC leaves receiver handling to discretion, and in practice nearly everyone treats permerror as fail-equivalent — so the practical effect is bad, but “every message fails SPF” is the wrong description and will mislead you when you read headers.

The fix is to merge everything into one record with one v=spf1, all the includes and one all. Do the arithmetic while you merge: two records that each cost 6 lookups become one record costing 12, which fails just as hard as having two did.

The eleventh lookup

include, a, mx, ptr, exists and the redirect modifier each cost a DNS query, and nested includes count too. The spec is blunt: implementations MUST limit those terms to 10 and MUST return permerror beyond it. Evaluation aborts. It does not fall through to your all.

Two details that trip people:

ip4, ip6, all and exp cost nothing. Google’s tree has historically come to four — one for _spf.google.com plus three netblock records — but re-dig it rather than trusting that number, and never hard-code Microsoft’s, which has moved more.

The two void lookups nobody counts

There is a second, separate limit almost every guide omits. A void lookup is one that returns NXDOMAIN or nothing, and the cap is two. Exceed it and you get permerror even with your term count comfortably under ten.

The usual cause is an include: still pointing at a vendor you stopped using and whose domain has since gone. Nothing about your record looks wrong, the count is fine, and mail stops authenticating. Check that every include: still resolves.

~all or -all

This argument is usually had backwards, so here is the shape of it.

For DMARC, fail and softfail are both simply not-a-pass, so the qualifier is irrelevant to DMARC. It matters only to receivers that act on SPF alone, and there -all invites them to reject. The ones that do are also the ones that bounce forwarded mail, because a forwarder that does not rewrite the Return-Path sends from an IP your record never listed.

So for a domain that sends real mail, ~all is the defensible 2026 default, with DMARC doing the enforcing. Google’s own documentation uses ~all, and Gmail and Proton deliberately avoid -all precisely to keep classic forwarding working. -all becomes right once every sender is known and you accept the forwarding breakage, or run SRS.

⚠ Contested, and it is fair to say so: Gmail is reported to penalise softfail-only senders in reputation scoring even when DMARC passes on DKIM, and some corporate filters treat softfail as hardfail. The clear-cut case for -all is a domain that sends nothing — how to lock one of those down.

Include order is not cosmetic

SPF is first-match. A ~all buried inside an earlier include: can short-circuit evaluation and return softfail before a later include that would have passed is reached. If you merged records and something that used to pass now softfails, look at the order before the contents.

Related: each TXT character-string caps at 255 octets, but one record may hold several and receivers concatenate them with no separator. A long record is "v=spf1 ... " "... ~all" — one record, two strings, which is a different thing entirely from two records.

Finding out, rather than guessing

dig +short TXT example.com          # one v=spf1 string, or you already have a bug
dig +short TXT _spf.google.com      # follow every include, add the querying terms up

⛔ PermError is silent. Nothing in DNS warns you, your logs stay clean, and the send succeeds. The only honest confirmation comes from the receiving side: send to a Gmail address, open the original, and look for spf=permerror in Authentication-Results. Your DMARC aggregate reports say the same thing a day later.

Flattening

Tools that replace includes with the literal IPs behind them do get you under ten, at the cost of a list that rots the moment a provider renumbers. If you use one, use one that re-flattens on a schedule. The version that never rots is not a flattener at all — it is keeping the apex out of the evaluation.

Connect a domain to Yowl →