How to check SPF lookup limit
Count the terms that cause a DNS query, include, a, mx, ptr, exists and redirect, following every include into the record it points at. RFC 7208 caps one evaluation at ten. Past ten the receiver returns permerror, and the record you published still looks correct.
Why check this
Run this whenever a team adds a sending tool, which is the only way the count grows. A helpdesk, a CRM and a newsletter platform each add one include, and the eleventh one breaks authentication for every message the domain sends, not only for the new tool. Nothing announces it. The record still parses, the text still reads sensibly, and the failure surfaces days later as delivery complaints from one region.
The reason this is worth a procedure rather than a glance is that the number you can see is not the number that counts. Includes nest. A provider can add an include to its own record and push you over the limit without touching yours, which makes this a check to repeat rather than a check to pass once.
Prerequisites
- RFC 7208 section 4.6.4, which defines the limit and the terms it counts.
- Node 22. Save this as
spf-count.js:
const { Resolver } = require('dns');
const r = new Resolver();
r.setServers(['8.8.8.8']); // name the resolver: Node's default is not always usable
const txt = d => new Promise(ok =>
r.resolveTxt(d, (e, recs) => ok(e ? [] : recs.map(c => c.join('')))));
// include, exists, a, mx, ptr and the redirect modifier are the terms that query DNS
const TERM = /^[-~?+]?(include|exists|a|mx|ptr)(?::([^/\s]+))?(?:\/\d+)?(?:\/\/\d+)?$|^(redirect)=(\S+)$/i;
const ALL = /^[-~?+]?all$/i;
let count = 0;
async function walk(domain, depth, path) {
const pad = ' '.repeat(depth);
const spf = (await txt(domain)).filter(s => /^v=spf1(\s|$)/i.test(s));
if (spf.length !== 1) return console.log(pad + ` ! ${domain}: ${spf.length} SPF records`);
const terms = spf[0].split(/\s+/).slice(1);
const hasAll = terms.some(t => ALL.test(t));
for (const term of terms) {
if (ALL.test(term)) break; // terms after 'all' are never evaluated
const m = TERM.exec(term);
if (!m) continue; // ip4, ip6 and exp cost nothing
const kind = (m[1] || m[3]).toLowerCase();
const target = (m[2] || m[4] || domain).toLowerCase();
if (kind === 'redirect' && hasAll) { // RFC 7208 section 6.1
console.log(pad + ` redirect=${target} ignored, the record has an 'all' mechanism`);
continue;
}
console.log(pad + `${String(++count).padStart(2)}. ${kind} ${target}`);
if (target.includes('%{')) {
console.log(pad + ' macro, cannot be expanded without a sending IP');
continue;
}
if (kind !== 'include' && kind !== 'redirect') continue;
if (path.includes(target)) { // a repeat on another branch is walked again
console.log(pad + ` ! ${target} is already on this path, a loop`);
continue;
}
await walk(target, depth + 1, [...path, target]);
}
}
const start = process.argv[2].toLowerCase();
walk(start, 0, [start]).then(() =>
console.log(`\n${start}: ${count} DNS-querying terms (RFC 7208 limit: 10)`));
Steps
- Step 1.
Count the lookup terms visible in the record text.
nslookup -type=TXT github.com 8.8.8.8 | grep -o 'include:[^ "]*'include:spf.protection.outlook.com include:_netblocks.google.com include:_netblocks2.google.com include:mail.zendesk.com include:_spf.salesforce.com include:servers.mcsv.net include:mktomail.com include:sendgrid.netEight. Read from the text, this record has room for two more sending tools.
- Step 2.
Count the lookups the evaluation performs, by following every include.
node spf-count.js github.com1. include spf.protection.outlook.com 2. include _netblocks.google.com 3. include _netblocks2.google.com 4. include mail.zendesk.com 5. include _spf.salesforce.com 6. exists %{i}._spf.mta.salesforce.com macro, cannot be expanded without a sending IP 7. include servers.mcsv.net 8. include mktomail.com 9. include sendgrid.net 10. include ab.sendgrid.net github.com: 10 DNS-querying terms (RFC 7208 limit: 10)Ten, not eight. The indented lines are the two that the record text does not mention: an
existsterm inside the Salesforce record, and a nestedincludeinside the SendGrid record. The domain is at the ceiling, and one more tool takes it over. - Step 3.
Run it on a domain whose record uses macros, to see what a static count cannot settle.
node spf-count.js mozilla.org1. include %{i}._ip.%{h}._ehlo.%{d}._spf.vali.email macro, cannot be expanded without a sending IP 2. include _spf.mozilla.com 3. include _netblocks.mozilla.com 4. include _netblocks3.mozilla.com 5. include _spf.google.com 6. include spf.fundraiseup.com 7. exists %{i}._spf.sparkpostmail.com macro, cannot be expanded without a sending IP mozilla.org: 7 DNS-querying terms (RFC 7208 limit: 10)A macro target is built from the connecting IP, so it cannot be resolved in advance. On
existsthat changes nothing, because the term costs one lookup either way. On line 1 the macro sits on aninclude, and the record it names stays unread along with any terms inside it. Seven is a floor here, not a total. - Step 4.
Confirm the counter agrees with a record that queries nothing.
node spf-count.js example.comexample.com: 0 DNS-querying terms (RFC 7208 limit: 10)v=spf1 -allpublishes a decision without a single query.ip4andip6terms are free in the same way, which is what record flattening trades on.
How to read the result
| What you see | What it means | What to do |
| --- | --- | --- |
| A total of 8 or fewer | The record has headroom | Record the number so the next tool addition is a decision, not a surprise. |
| A total of 9 or 10 | One provider change away from failing | Replace the largest include with the ip4 ranges it resolves to, and re-check monthly. |
| A total above 10 | Every evaluation ends in permerror | Flatten or remove includes now. Nothing else in the record matters until it is under the cap. |
| A macro line under an include | The record it names was not read | The total is a lower bound. Under exists the same line leaves the total exact. |
| A ! domain: 0 SPF records line | An include or redirect names a domain with no SPF record | RFC 7208 sections 5.2 and 6.1 make that term a permerror whenever evaluation reaches it, whatever the count. Fix or remove it. |
| An is already on this path, a loop line | Two records include each other | The evaluation cannot finish, and the ten-term cap ends it in permerror. Break the loop. |
Thresholds
Common mistakes
What to check next
- How to check SPF record: read the record itself, including the part a grep cuts off.
- How to check DMARC record: a
permerroris not a pass, and the DMARC policy decides what happens next. - How to check txt record of a domain: the TXT set the record shares with every vendor verification string.
- How to check dns records: the wider record inventory to take before a cutover.
- Email testing checklist: where this check sits in a release.
FAQ
What is the SPF record lookup limit?
Ten terms that query DNS, per evaluation, from RFC 7208 section 4.6.4. include, a, mx, ptr, exists and redirect count. ip4, ip6, all and exp do not.
What happens when an SPF record has too many DNS lookups?
The receiver stops and returns permerror. DMARC treats that as an SPF failure, so a domain at p=reject with no aligned DKIM signature loses its mail even though every listed sender was legitimate.
Does the limit count the lookup for the record itself?
No. The initial TXT query for the domain is outside the ten. The count starts at the first include, a, mx, ptr, exists or redirect term.
Why does a checker report a different number than mine?
Usually a redirect that one side counted and the other ignored, a record included from two branches that one side walked only once, or a provider that changed its own record between the two runs. Re-run both against the same resolver before treating the gap as a bug.
Verified
Verified by Maks Vernynode 22.23.2nslookup Windows 10.0.22631
Each output block is what the command above it printed on that date, on the host named in the step. Figures read from a live site move between runs. Compare the shape of the answer rather than the digits, and see the methodology for how a page is re-verified.
Related on this site
intermediate6 minpublished updated Maks Verny