SPF has a limit almost nobody hits on purpose and plenty of people hit by accident: a receiver is allowed to make ten DNS lookups while evaluating your record, and if the chain needs an eleventh it returns permerror and throws your entire SPF away. Not the eleventh mechanism — the whole record. Your carefully maintained list of authorised senders stops meaning anything, and nothing anywhere tells you.
Counting those ten looks like a five-line function. I wrote it, wrote unit tests for it, and then ran it against the top 500 domains of the Majestic Million to see what the real-world distribution looked like.
The distribution was a footnote. The interesting part was that the scan found four bugs in my counter, and none of my existing unit tests had caught any of them.
The rule everyone gets wrong first
include: recurses, and the lookups inside the included record count toward your ten.
example.com v=spf1 include:_spf.vendor.com -all
_spf.vendor.com v=spf1 a mx include:relay.vendor.com -all
relay.vendor.com v=spf1 a mx -all
That is not one lookup. It is include (1) + a mx (2) + include (1) + a mx (2) = six. Counting only the top-level include: mechanisms — which is the shape most naive implementations take — reports one and tells the user everything is fine.
Which mechanisms cost a lookup is also less obvious than it should be. include, a, mx, ptr, exists and the redirect= modifier all cost one each. ip4, ip6, all and the exp= modifier cost nothing. So redirect= counts but exp= does not, and they are both modifiers with identical syntax.
Bug 1: redirect is free when all is present
RFC 7208 §6.1 says receivers must ignore redirect= entirely if the record also contains an all mechanism. Ignore means no query, which means no lookup.
My code charged for it anyway. On one test record that turned a true count of 1 into a reported 7 — enough to fire a false SPF_TOO_MANY_LOOKUPS on a domain that was completely fine.
A false positive here is worse than a miss. Telling someone their working SPF is broken costs you their trust immediately.
Bug 2: bare a and mx resolve against the wrong domain
Inside an include:, a bare a or mx with no domain attached evaluates against the current domain — the included one — not the domain you started the analysis from. RFC 7208 §5.3.
My expander kept resolving against the original domain, so it queried names that had nothing to do with the record it was reading and mis-attributed the void lookups that resulted.
Bug 3: the uncertainty was pointed backwards
This is the one I keep thinking about, because it is not a coding mistake. It is a reasoning mistake that produced correct-looking output.
Expansions fail partway. DNS times out, a name disappears, a macro like exists:%{i}._spf.mta.salesforce.com cannot be evaluated without a live sender. So sometimes you finish with an incomplete walk and a partial count.
The instinct is to mark partial results as unreliable. But partial expansion has a direction: it can only ever undercount. The part you did not reach can only add lookups, never remove them.
So:
- a partial count already over 10 is certain — the unexplored remainder can only push it further over;
- a partial count under 10 proves nothing at all.
I had it exactly backwards. All four genuinely over-limit domains were flagged "needs review", while healthy-looking partial results were passed through as confirmed.
The macro case falls out of the same reasoning. A macro only creates uncertainty in include: and redirect= — the two mechanisms that recurse, where not knowing the target means not knowing what is inside it. A macro in exists: or a: costs exactly one lookup whatever it expands to, so the count stays exact.
That distinction was not academic. 122 of the 433 SPF publishers in the scan carry a macro, nearly all of them the Salesforce one above. Treating any macro as poisoning the count marked 29% of the dataset unreliable and flooded the review queue with 153 rows of noise.
Bug 4: the scan harness corrupted its own results
The scanner is resumable and appends one JSONL row per attempt, so a domain scanned three times has three rows. Collapsing to one row per domain, I kept the most recent.
who.int has the deepest chain in the top 500 — 17 lookups. It was measured correctly at 17 four separate times. Then a fifth attempt, run over a lossy link, reached only 10 and reported no problem at all. That was the row the report used.
The fix is to keep the fullest expansion rather than the newest, which is only sound because of the one-way property from bug 3. A worse network gives you a smaller number, never a bigger one, so "most complete" is a safe ranking.
The domain that had SPF but appeared not to
I wrote a second lookup counter in Python, straight from the RFC over dig, deliberately not derived from the Go code, so that agreement between them would be evidence rather than a restatement. Fifteen domains, zero discrepancies in logic.
Except one. wisc.edu: my Go implementation said 3 lookups, the Python checker said no SPF record at all.
The checker was wrong, and the reason is worth carrying around. wisc.edu publishes 22 TXT records. The response does not fit in a 512-byte UDP packet. A client that does not advertise EDNS0 and does not retry over TCP receives a truncated answer, finds no v=spf1 string in it, and concludes the domain publishes no SPF.
That would be a critical finding, a 25-point deduction, against a correctly configured university. Produced by a resolver default, not by anything about the domain.
If you are writing anything that reads DNS and draws conclusions, make sure your resolver does EDNS0 and TCP fallback. The failure is silent and it looks exactly like a real finding.
What the 500 domains actually showed
The scan was supposed to prove that lookup overflow is common. It isn't:
| count | share | |
|---|---|---|
| publishes SPF | 433 / 500 | 86.6% |
| over the 10-lookup limit | 4 | 0.9% |
| permerror (multiple records / syntax) | 3 | 0.7% |
+all / ?all — no real protection |
14 | 3.2% |
| no SPF record at all | 67 | 13.4% |
Nought point nine percent. The headline I had in mind — "X% of major domains have SPF that silently no longer works" — was not available at that number, and I dropped it.
Two things the same data does support. 21 domains sit at exactly 10, one added vendor away from tipping over. And the breadth is worse than the depth: 13.4% publish nothing at all, 3.2% publish something that authorises the entire internet.
Bear in mind this list is the most professionally managed tail of the web. Whatever the rate is among small business domains, it is not lower.
Latency is chain depth, and nothing else
Once it was running as a service, the thing I most wanted to optimise turned out not to be optimisable.
An SPF chain is serial by construction. Each include: has to be resolved before you know what the next one is. No amount of concurrency changes that, because the work has a dependency order.
So response time is close to a pure function of chain depth. Measured on a 1 vCPU box with a local unbound:
| domain | lookups | cold | provider names prewarmed | saved |
|---|---|---|---|---|
| shopify.com | 4 | 303ms | 109ms | 64% |
| stripe.com | 7 | 826ms | 83ms | 90% |
| who.int | 17 | 3274ms | 2032ms | 38% |
Prewarming means a timer that keeps the common provider SPF targets — _spf.google.com, spf.protection.outlook.com, sendgrid.net and about thirty others — resident in the local resolver cache.
Stripe is the interesting row. Four of its seven lookups go through include:spf1.stripe.com, Stripe's own name, which is obviously not on any prewarm list. I predicted a modest gain. It came out at 90%, because the end of that chain is Amazon SES and Zendesk, which are. A self-hosted include is a hop, not a cost. The recursion that takes time happens at the provider domains it eventually points to.
who.int is the counterexample that keeps you honest: a good part of its 17 lookups are names only it uses, and nothing can warm those. It stays at two seconds.
Which meant the flat "p95 under 500ms" target I had written down was wrong — not missed, wrong. It is comfortable for most domains and physically unreachable for deep ones, by any implementation. The honest thing to publish is the distribution, not an average that hides it.
The checker is a Go service now, and the version with the four bugs fixed is on RapidAPI as Email Verification and Domain Health if you want to point it at a domain. But the part worth taking away is cheaper than that: if you maintain a domain that sends mail, expand your own SPF chain and count. You are allowed ten, 21 of the 500 I scanned are sitting on exactly ten, and the failure mode is silent.
Top comments (0)