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:
include:foocosts one, plus every querying term inside foo. Not one plus a free subtree.mxcharges you for the MX records it finds, against the same budget. A domain with three MX hosts can spend four lookups on onemxmechanism.
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.