Staying Under the SPF 10-Lookup Limit: Flattening, Include Audits, and Killing PermError
The SPF 10-DNS-lookup cap is a hard runtime limit, not a guideline. Blow past it and every receiver returns PermError, which silently breaks your DMARC alignment. Here's how to audit the real expanded count, fix it without flattening, and flatten safely when you must.
EvilMail TeamJuly 17, 202611 min read
Mail to one big provider starts sliding into spam. You run dig and the SPF record looks fine — a tidy line of five includes ending in ~all, nothing obviously broken. Then you paste the domain into an SPF validator and it comes back with the real problem:
PermError: too many DNS lookups (11)
That single error is worse than it looks. When SPF hits PermError, most receivers evaluate it as if you published no SPF record at all — none or neutral. Under DMARC, that quietly removes one of your two alignment paths, leaving you leaning entirely on DKIM. One rotated signing key or one forwarded message later, DMARC fails, and mail that used to pass starts bouncing or landing in the junk folder. The record still "looks right" in dig, which is exactly why this bug survives for months.
The rule everyone gets wrong
RFC 7208 §4.6.4 is blunt: an SPF evaluation may perform at most 10 DNS-querying lookups
. Cross that line and the result is PermError. The part people miss is that the budget is
total, across the entire recursive evaluation
— not per record, not per include.
The mechanisms and modifiers that each cost one lookup:
include
a
mx
ptr
exists
the redirect= modifier
What is free and never counts against the budget:
ip4
ip6
all
exp
So a record with fifty ip4: entries costs zero lookups, while a record with eleven bare includes is dead on arrival. Three more traps are worth knowing:
`mx` fans out. Each mx costs one lookup for the MX set, then resolves the A/AAAA records behind every host — and RFC 7208 caps it at 10 MX records, throwing PermError past that. A domain with a sprawling MX set can trip that sub-limit on its own.
The void-lookup limit. Separate from the 10-lookup cap, RFC 7208 allows at most 2 "void" lookups — queries returning NXDOMAIN or NODATA — before it throws PermError. A dead include that no longer resolves doesn't just waste a slot; two of them and you're done.
`ptr` is dead. RFC 7208 §5.5 says ptr SHOULD NOT be used. It's slow, unreliable, and some receivers ignore it entirely. If it's in your record, delete it today.
One more constraint: each TXT string maxes out at 255 characters, and you want the whole record to fit in a single 512-byte UDP response so resolvers don't fall back to TCP. A giant flattened record can trip this even when the lookup count is zero.
And the fact that makes half the fixes below possible: SPF only ever checks the MAIL FROM / Return-Path domain (and HELO) — never the visible From: header. That address is DMARC's concern. This is why a sending subdomain buys you real headroom instead of just moving the problem around.
Audit first: count what actually resolves
The core mistake is counting the includes you can see. Five includes does not mean five lookups — it means five *entry points* into trees you don't control. include:_spf.google.com alone expands into three or four sub-lookups because Google nests _netblocks, _netblocks2, and _netblocks3 behind it.
Start by pulling the live record:
bash
dig +short TXT evilmail.pro | grep 'v=spf1'
Then follow a single include one level down to see the nesting:
Eyeballing that tree is error-prone. Get an authoritative machine count from a real SPF library — pyspf implements RFC 7208 including the lookup and void counters, so it returns permerror exactly when a receiver would:
bash
python3 -c "import spf; print(spf.check2(i='1.2.3.4', s='[email protected]', h='mail.evilmail.pro'))"
# ('permerror', 'Too many DNS lookups')
Stack the common enterprise five — Google (≈3-4) + Outlook (≈2-3) + SendGrid (≈1-2) + Mailchimp (≈1) + Salesforce (≈1) — and you are already brushing the ceiling before adding your own mx or a monitoring vendor. That's the bloated starting point almost every mid-size org lands in:
Flattening is where most guides jump straight to, and it's the wrong first move. Work this list in order — it's sorted by durability, cheapest and most permanent first.
1. Delete dead senders. That ESP you trialed in 2023 is almost certainly still in the record, still costing a lookup, and if its include domain has since gone away it's also burning a void lookup. Cross-reference every include against what's actually sending today. This alone routinely drops the count by two or three.
2. Split bulk and transactional mail onto a sending subdomain. Because SPF checks the MAIL FROM / Return-Path domain, marketing blasts sent with a bounce domain of mktg.evilmail.pro are evaluated against *that* domain's SPF — not your apex. Move the heavy ESP includes there and keep the apex lean:
; apex — only what the apex actually sends
evilmail.pro. TXT "v=spf1 include:_spf.google.com -all"
; dedicated bulk/return-path domain — its own 10-lookup budget
mktg.evilmail.pro. TXT "v=spf1 include:sendgrid.net include:servers.mcsv.net -all"
This is free headroom: each domain gets its own independent 10-lookup budget. Point your ESP's Return-Path/bounce domain at the subdomain and it just works.
3. Replace `include:` with literal `ip4`/`ip6` — but only for providers who publish stable, documented ranges. Some infrastructure vendors commit to fixed netblocks. Inlining those as ip4: costs zero lookups and never goes stale. Do not do this for Google, Microsoft, or Amazon SES, whose ranges move without notice.
4. Consolidate with `redirect=` only when you fully own the target. And know the tradeoff: redirect costs a lookup exactly like include. It saves you nothing on the budget — it's an organizational tool for stitching together domains you control, not a way to shrink the count.
Flattening: the last resort, done right
Flattening means recursively resolving every include down to raw ip4/ip6 CIDRs and inlining them, so the lookup count drops to effectively zero:
Elegant on paper. The catch: you've frozen a moving target. Google, Amazon SES, and Microsoft rotate their sending ranges constantly and never notify you. The day Google adds a /24, your flattened record no longer lists it, and legitimate mail from that block fails SPF — silently, with no error in your logs, because the record is technically valid. You didn't fix a problem; you scheduled an outage for an unknown future date.
So flattening is only acceptable with automation behind it. A flattened record without a scheduled re-resolve and drift alerting is not a solution — it's a time bomb. The loop you need: a scheduled job re-expands every source include to current CIDRs, diffs the result against the live TXT, and if they differ, pushes the new record via your DNS provider's API and pages a human.
If you want dynamism without freezing anything, SPF macros are the advanced route: exists:%{i}._spf.evilmail.pro lets you answer per-source-IP with a small custom DNS responder that returns the right result at query time. It keeps the record tiny and always-current, at the cost of running and maintaining that responder. Most teams don't need it — but it beats a hand-maintained flattened record.
Verify and monitor
Prove the fix against a real receiver instead of trusting a web checker. swaks sends a live message and shows you exactly what the receiving side computed:
Once you're confident the record is complete and stable, tighten the qualifier from ~all (softfail) to -all (hardfail). Softfail tells receivers "probably not us, but don't reject"; hardfail is an unambiguous "if it's not on this list, it's forged," and it's what you want backing a p=reject DMARC policy.
Then monitor continuously. The regression you're guarding against isn't dramatic — it's someone in marketing adding a sixth ESP that pushes you from 9 to 11 lookups on a Tuesday. Schedule the pyspf count against your live record and alert when it exceeds a threshold; 8 is a sane ceiling, leaving slack for one more sender before you overflow. Catch the breach the hour it happens, not the week sales files a ticket about deliverability.
Pre-flight and ongoing checklist
Count the recursive expansion, not the includes — use pyspf/check2, not your eyes.
Delete every include with no active sender behind it — dead ones also burn void lookups.
Kill `ptr` entirely (RFC 7208 §5.5) and audit any mx that fans out widely.
Move bulk/transactional mail to a sending subdomain with its own SPF and its own 10-lookup budget.
Inline `ip4`/`ip6` only for providers with stable, documented ranges — never Google/Microsoft/SES.
Keep each string under 255 chars and the record inside 512 bytes to avoid TCP fallback.
If you flatten, ship the automation with it — re-resolve on a schedule, diff, publish via API, page on drift.
Tighten to `-all` once the record is proven complete.
Alert at 8 lookups, verify end-to-end with swaks, and confirm the receiver header shows spf=pass.