DEV Community

Cover image for My SPF checker said GitHub was at 8 of 10 DNS lookups. The real number is 10.
ExamineIP
ExamineIP

Posted on AI-assisted

My SPF checker said GitHub was at 8 of 10 DNS lookups. The real number is 10.

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 an all (§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 mx gets 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.com gets its own SPF record and its own 10 lookups. DMARC's relaxed alignment still accepts it for example.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)

Collapse
 
sinarezaei profile image
Sina Rezaei •

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.

Collapse
 
supportdev profile image
DEV SUPPORTS •

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

‌‌‌