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

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

  1. 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.net

    Eight. Read from the text, this record has room for two more sending tools.

  2. Step 2.

    Count the lookups the evaluation performs, by following every include.

    node spf-count.js github.com
    
     1. 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 exists term inside the Salesforce record, and a nested include inside the SendGrid record. The domain is at the ceiling, and one more tool takes it over.

  3. Step 3.

    Run it on a domain whose record uses macros, to see what a static count cannot settle.

    node spf-count.js mozilla.org
    
     1. 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 exists that changes nothing, because the term costs one lookup either way. On line 1 the macro sits on an include, and the record it names stays unread along with any terms inside it. Seven is a floor here, not a total.

  4. Step 4.

    Confirm the counter agrees with a record that queries nothing.

    node spf-count.js example.com
    
    example.com: 0 DNS-querying terms (RFC 7208 limit: 10)

    v=spf1 -all publishes a decision without a single query. ip4 and ip6 terms 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

10 DNS-querying terms per SPF evaluation Source: RFC 7208 section 4.6.4: SPF implementations MUST limit the total number of those terms to 10 during SPF evaluation, to avoid unreasonable load on the DNS
2 void lookups per SPF evaluation Source: RFC 7208 section 4.6.4: SPF implementations SHOULD limit void lookups, terms whose query returns no answer or NXDOMAIN, to two
10 address records per mx or ptr mechanism Source: RFC 7208 section 4.6.4: the evaluation of each MX record MUST NOT result in querying more than 10 address records

Common mistakes

Sign: The record has eight include terms and a checker reports ten lookups.Cause: Includes are evaluated recursively and the extra lookups are in somebody else's record. On 2026-09-15, include:sendgrid.net cost two because that record ends in include:ab.sendgrid.net, and include:_spf.salesforce.com cost two because that record carries an exists term. Counting the terms you published is not counting the lookups.
Sign: Two checkers disagree about a record that carries a redirect modifier.Cause: RFC 7208 section 6.1 says a redirect modifier MUST be ignored when the record also has an all mechanism, whatever the order of the terms. It then causes no query and costs nothing. A counter that scans text rather than evaluating the record charges you for a lookup that never happens.
Sign: The count is comfortably under ten and messages still get a permerror.Cause: An include whose target no longer publishes an SPF record returns permerror under RFC 7208 section 5.2, however low the count. The script prints it as a ! line with 0 SPF records. A third void lookup, an a, mx or exists term whose query comes back empty, also ends in permerror, and the script cannot see that one because it does not query those targets.
Sign: Flattening the record to ip4 ranges fixes the count and breaks delivery weeks later.Cause: Flattening copies a provider's addresses into your record and freezes them. The provider keeps changing its own record, which you no longer read, so the copy drifts. A flattened record needs a scheduled re-check, which the flattening tool does not give you.

What to check next

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.

intermediate6 minpublished updated Maks Verny