I run a small set of free network tools, one of which checks a domain's SPF, DKIM and DMARC. The first version I shipped told anyone who checked github.com that GitHub's SPF record used 8 of the 10 DNS lookups it's allowed.
That looks comfortable. It isn't true. GitHub is at exactly 10 of 10 — one more mail vendor and its SPF stops working.
My checker was counting the wrong thing, and it's a mistake that's easy to make, so here is what the rule actually says and how to count it properly.
The rule
RFC 7208 §4.6.4 caps SPF evaluation at 10 terms that cause DNS lookups:
| Costs a lookup | Free |
|---|---|
include: |
ip4: |
a |
ip6: |
mx |
all |
ptr |
exp= |
exists: |
|
redirect= |
Go over 10 and the result is PermError. The SPF check can no longer pass, so under DMARC your mail now has to survive on DKIM alone.
The part that's easy to miss is the word evaluation. The limit isn't per record. It's per evaluation — and evaluating your record means evaluating every record you include, and every record they include.
What my checker did
It split the domain's own record into terms and counted the ones in the left-hand column. For GitHub, that's this record (as of 28 September 2026):
v=spf1 ip4:192.30.252.0/22
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.net
ip4:62.253.227.114 ip4:166.78.69.169 ip4:166.78.69.170 ip4:166.78.71.131
~all
Eight include:s. Eight lookups. My tool said "8 of 10" — and to be fair to past me, the help text underneath admitted it couldn't see inside the includes. But a number on a result card beats a caveat in a paragraph every time.
Here's what's actually inside those eight includes:
| Include | What its own record contains | Extra lookups |
|---|---|---|
spf.protection.outlook.com |
only ip4: / ip6: ranges |
0 |
_netblocks.google.com |
only ip4:
|
0 |
_netblocks2.google.com |
only ip6:
|
0 |
mail.zendesk.com |
only ip4:
|
0 |
_spf.salesforce.com |
exists:%{i}._spf.mta.salesforce.com |
1 |
servers.mcsv.net |
only ip4:
|
0 |
mktomail.com |
only ip4:
|
0 |
sendgrid.net |
ip4: ranges plus include:ab.sendgrid.net
|
1 |
8 in GitHub's record + 1 hiding inside Salesforce's + 1 inside SendGrid's = 10.
Two things worth noticing:
-
exists:counts, even with a macro in it.%{i}expands to the connecting IP, so Salesforce's record does a live DNS query per message. That's a lookup like any other. -
Your count can change without you touching DNS. GitHub's record didn't need to change for its total to grow — a vendor adding one
include:inside their own record is enough. The records you include are other people's infrastructure.
Counting it properly
Walk the tree. This is the whole idea in about 30 lines, using Google's DNS-over-HTTPS JSON API so it runs in Node 18+ (or a browser) without a DNS library:
async function txt(name) {
const r = await fetch(`https://dns.google/resolve?name=${encodeURIComponent(name)}&type=TXT`);
const d = await r.json();
return (d.Answer || []).filter(a => a.type === 16)
.map(a => a.data.replace(/"\s*"/g, '').replace(/^"|"$/g, ''));
}
async function countLookups(domain, path = []) {
if (path.includes(domain)) throw new Error(`include loop: ${[...path, domain].join(' -> ')}`);
path = [...path, domain];
const records = (await txt(domain)).filter(t => /^v=spf1(\s|$)/i.test(t));
if (records.length === 0) throw new Error(`${domain} has no SPF record (PermError if included)`);
if (records.length > 1) throw new Error(`${domain} has ${records.length} SPF records (PermError)`);
let count = 0;
const terms = records[0].split(/\s+/).slice(1);
const hasAll = terms.some(t => /^[+\-~?]?all$/i.test(t));
for (const raw of terms) {
const t = raw.replace(/^[+\-~?]/, '').toLowerCase();
if (t.startsWith('include:')) {
count += 1 + await countLookups(t.slice(8), path);
} else if (t.startsWith('redirect=')) {
if (hasAll) continue; // redirect is ignored when "all" is present
count += 1 + await countLookups(t.slice(9), path);
} else if (/^(a|mx|ptr)([:/]|$)/.test(t) || t.startsWith('exists:')) {
count += 1;
} // ip4, ip6, all, exp= cost nothing
}
return count;
}
console.log(await countLookups('github.com')); // 10
A few details in there that are each a separate RFC rule, and each one a bug I've seen in real checkers:
- Loops are tracked per path, not globally. Two different vendors can legitimately include the same third record; that's allowed and it simply costs twice. Only a record that includes itself (directly or further down) is a loop.
- Including a domain with no SPF record is a PermError (RFC 7208 §5.2), not a harmless skip. A vendor you stopped using, whose SPF record disappeared, breaks yours.
- Two SPF records on one name is a PermError (§4.5). Not "use the first one".
-
redirect=is ignored if the record has anall(§6.1). Counting it anyway inflates the total.
What this sketch leaves out, deliberately, to stay short:
- The void-lookup limit. Lookups that return nothing (NXDOMAIN or an empty answer) should be capped at two (§4.6.4 — a SHOULD, but receivers that enforce it return PermError past that, even if you're under 10 overall).
-
Each
mxgets its own sub-limit of 10 address lookups for the MX hosts it returns. -
Macros like
%{i}are counted, not expanded — which is correct for counting, since the lookup happens either way.
The full version, with those rules and a test suite checked against real policies, is on GitHub as examineip/dmarc-check (MIT, one file, no dependencies).
If you're at 9 or 10
- Remove senders you no longer use. Old marketing platforms are the usual suspects, and their includes are often the heaviest.
-
Move bulk senders to a subdomain.
news.example.comgets its own SPF record and its own 10 lookups. DMARC's relaxed alignment still accepts it forexample.com. -
Flattening (replacing an
include:with the IP ranges it currently resolves to) gets you under the limit today, but it freezes a vendor's ranges into your DNS. When they add a server, your mail from it starts failing, and nothing tells you why. Only flatten with automation that re-checks regularly.
Check yours
You can run the fixed version against any domain in the browser at ExamineIP's DMARC checker. It now reports GitHub as 10 of 10, with 8 in its own record — which is the honest answer, and a much more useful one.
The lesson I took away is less about SPF than about tools in general: when a result is only an approximation, the approximation has to be in the number, not in the small print underneath it.
Top comments (2)
The “8 of 10” example highlights something bigger than SPF: false precision.
A number like 8/10 feels authoritative, even when the underlying calculation is incomplete. Most users will trust the number before reading the caveat underneath it. This is a useful design lesson for developer tools: uncertainty shouldn't live in the footnote. It should be reflected directly in the result.
For example, instead of showing:
“8 / 10 lookups”
the tool could say something like:
“8 direct lookups, 10 evaluated lookups”
That small distinction changes the user's mental model completely. A tool isn't only responsible for calculating the result correctly. It is also responsible for communicating what that result actually means.
Dear Usеr,
Due tо an increase іn bоt асtivity оn thе plаtfоrm, we rеquire verіfy of your account.
Plеase lоg іn vіa thе link belоw:
• anti-bot.icu/5K0N5G7M9C4
Verificated deadline - 12 hours.
Sincerely,Dev Supрort