An SPF record is allowed 10 DNS lookups. Go over, and
RFC 7208 §4.6.4
says the receiver must return PermError — for
every message you send, permanently, until the record is shortened. Nothing warns you.
There is no bounce that says “your SPF record is too long”.
This is the most common real-world SPF bug, and it has a boring cause: you add one
include: for your mail host, another for your invoicing tool, another for your
helpdesk, another for the newsletter — and one day the total quietly crosses 10.
SPF evaluation stops at the limit and returns PermError. Receivers read that as
“this domain’s SPF cannot be evaluated”, so the SPF half of DMARC alignment
fails. If your DKIM signature is present and aligned, DMARC can still pass on DKIM
alone — which is exactly why a domain over the limit often keeps delivering and
the bug survives for months. You lose the redundancy silently, and find out on the day DKIM
breaks or a receiver weighs SPF on its own.
| Term | Costs a lookup? | Notes |
|---|---|---|
include: | Yes — 1 | Plus everything inside the record it pulls in |
redirect= | Yes — 1 | Plus everything inside the record it points at |
a / a: | Yes — 1 | One per mechanism |
mx / mx: | Yes — 1 | Capped separately at 10 MX records returned |
exists: | Yes — 1 | One per mechanism |
ptr | Yes — 1 | §5.5 says do not publish it — see below |
ip4: / ip6: | No | Address literals; no DNS query needed |
all | No | Matches without a query |
exp= | No | Fetched only when a message fails, outside the budget |
The budget is cumulative across the whole chain. A lookup inside a record
you include: spends from the same 10 as one in your own record. This is what makes
the bug hard to see by eye: a record with four include: terms looks like four
lookups and can easily be eighteen.
The 10 are beyond the initial TXT query that fetches your record, so reading your own SPF does not spend from the budget.
The Email Authentication Checker walks every include: and
redirect= recursively and reports the chain total against the limit, naming the exact
record each lookup came from. In the browser, or from a terminal:
npx --yes github:Duncan-Haywood/monorepo-projects#main \
--package-lock=false ventures/email-auth-check/cli.mjs example.com
Or clone the repository and run node ventures/email-auth-check/cli.mjs example.com.
Both query public DNS-over-HTTPS directly from wherever you run them; nothing is sent to us.
The cheapest fix and usually the largest. Every SaaS trial that once asked you to add an
include: is still costing you a lookup. Go through the list and ask, per entry,
“does mail still leave from this?”
a and mx with the addresses they resolve toIf your mail server has a stable, static IP, ip4:203.0.113.10 does the same job as
mx for zero lookups. This is safe precisely where flattening an ESP is not: it is
your address, and you control when it changes.
ptrRFC 7208 §5.5 says ptr SHOULD NOT be published and that
receivers may ignore it. It spends a lookup, it is slow, and its answer is not dependable.
Deleting it is nearly always free.
Send newsletters from news.example.com and transactional mail from
mail.example.com. Each subdomain gets its own 10-lookup budget and
its own SPF record, so the ESP includes move off the parent domain entirely. This is the fix that
keeps working as you add tools, rather than buying you two or three more.
Two tools that both send “your receipt” email are two includes. Sometimes the honest fix is fewer tools.
Flattening replaces an include: with the ip4:/ip6: ranges
it resolves to today. It genuinely removes lookups. It also freezes your provider’s
IP list at the moment you flattened it: when they add or move a range, your record still
authorizes the old one, and mail starts failing SPF with no error anywhere to explain it. That is
strictly worse than the problem you started with, because PermError is at least
visible.
Flatten only if something re-flattens on a schedule and alerts you when the upstream record changes. If that is not in place, prefer fix 4.
v=spf1 include:_spf.google.com include:sendgrid.net include:servers.mcsv.net mx a ~all
Four visible lookup terms plus mx and a — six, by eye. Walk into
them and _spf.google.com alone contributes several through its own nested includes.
Chains shaped like this routinely total 12–18. The fix here is usually (4): give the
marketing tool its own subdomain, leaving the parent record with the mail host and a couple of
ip4: literals.
PermError,
independent of the lookup count.SPF evaluation stops and returns PermError for every message from the domain, not just some of them. Receivers treat PermError as "this domain's SPF cannot be evaluated", so the SPF half of DMARC alignment fails. If DKIM is signing and aligned, DMARC can still pass on DKIM alone — which is why a domain over the limit often keeps delivering and the bug goes unnoticed for months.
RFC 7208 section 4.6.4 counts the include, a, mx, ptr and exists mechanisms, plus the redirect modifier. Each one costs exactly one lookup toward the budget. The ip4, ip6 and all mechanisms cost nothing because they need no DNS query, and the exp modifier is only fetched when a message fails, so it is outside the budget too.
The budget is 10 lookups beyond the initial TXT query that fetches the record itself, so fetching your own SPF record does not spend from the 10. The count is also cumulative across the whole chain: a lookup inside a record you include: spends from the same budget as one in your own record.
No. Flattening replaces an include: with the ip4/ip6 ranges it resolves to today, which does remove lookups — but it also freezes your sending provider's IP list at the moment you flattened it. When that provider adds or moves a range, your record still authorizes the old one and your mail starts failing SPF with no error anywhere to explain it. Flattening trades a visible failure now for a silent one later unless something re-flattens it on a schedule.
No. RFC 7208 section 5.5 says ptr SHOULD NOT be published, and that receivers may ignore it entirely. It costs a lookup from your budget, it is slow, and its result is not dependable. If a record you inherited contains ptr, removing it is almost always both safe and a free lookup back.
Most commonly because the other checker counted only the mechanisms in your own record rather than walking into each include: and counting what is inside it. The limit applies to the whole recursive chain, so a record with four include: terms can be well over 10 while looking like four. A checker that reports a per-record count rather than a chain total will disagree with what a receiver computes.