Diagnostic Email Authentication

SPF exceeds the 10 DNS lookup limit

You added Google Workspace, then Microsoft 365, then SendGrid, then Mailchimp, then HubSpot, one include at a time, and each one seemed harmless on its own. Now mail that should pass SPF is failing, and nobody changed the sending server. The record itself looks fine to read. The problem is buried in how many DNS lookups it actually costs once every include is followed all the way down, and that number just walked past 10.

dig and SPF checkers Python and Node.js Fixable through the DNS API
Opened envelopes
Photo by sue hughes on Unsplash
The short answer

RFC 7208 caps SPF evaluation at 10 DNS-lookup-causing mechanisms per check: include, a, mx, ptr, exists, and the redirect modifier, counted recursively through every nested include. Stacking a few vendor includes (Google Workspace, Microsoft 365, SendGrid, Mailchimp, HubSpot) is enough to pass 10 once their own nested includes are counted. Once the real count is over 10, receivers must return a PermError, and DMARC-enforcing mail servers treat that as an SPF failure, even for genuinely authorized senders. Check with dig +short TXT example.com | grep spf1 and walk every include by hand, or run an SPF checker that reports the count directly. The fix is to remove unused includes, replace static senders with ip4:/ip6: mechanisms that cost zero lookups, and flatten the rest into one pre-resolved include. Full commands, records, and a script are below.

The problem in plain words

An SPF record is a short list of rules that says which mail servers are allowed to send email for your domain. Some of those rules point straight at an IP address, which any receiving mail server can read for free. Other rules, like include:_spf.google.com, tell the receiver to go look up another domain's SPF record and check that one too. Each one of those lookups costs something, and RFC 7208 only allows 10 of them per message, total, no matter how many vendors you have added.

The tricky part is that the includes you add are rarely just one lookup each. Google's own SPF record nests three or four more includes inside it. Microsoft's does the same. So the include you added because it looked like "just one line" can secretly cost four or five lookups once the receiver follows it all the way down. Add a few of these and you can be well over 10 without the raw text of your record looking unusual at all.

Receiver reads v=spf1 include... Follows every include, recursively Google, Microsoft, more... count passes 10 PermError per RFC 7208 spf=permerror treated as fail
Every include the receiver has to follow, at any depth, adds to the same running total. Past 10, the whole record errors out, regardless of whether the sending IP was authorized.

Why it happens

The key insight

The 10 lookup limit exists in RFC 7208 section 4.6.4 to protect receivers from being made to chase an unbounded chain of DNS lookups on every incoming message. It is not a soft warning. Once the real, recursive count is over 10, the receiver is required to stop and return PermError, and it must do that even if the actual sending IP would have matched further down the chain. A record that looks fine on the surface can still be broken underneath it.

The fix, as a flow

Pull the record, walk every include recursively by hand or with a script, and get an exact lookup count instead of a guess. If the count is over 10, cut it down: drop anything unused, turn what you can into direct ip4:/ip6: mechanisms that cost nothing, and flatten the rest into one pre-resolved include. Then publish the shorter record and confirm the count is back under the limit.

Pull the record dig TXT example.com Count lookups recursively, every include Trim and flatten drop unused, use ip4/ip6 Count now 10 or less? yes no, trim more Publish and verify re-check count and spf=pass
Once the real lookup count is 10 or fewer, receivers can finish evaluation and return a real pass or fail instead of a PermError.

How to fix it

1

Pull the raw SPF record

Start by reading the exact TXT record that is live right now. This is the string every mail server actually evaluates, so it is the only thing worth trusting over memory of what was set up.

Terminal
pull-record (shell)
dig +short TXT example.com | grep spf1

# a record that is already in trouble looks like this:
# "v=spf1 include:_spf.google.com include:spf.protection.outlook.com include:sendgrid.net include:servers.mcsv.net ~all"
2

Walk every include by hand and recurse

Look up each include's own TXT record, then look up any includes or redirects that record points to, and keep going until nothing is left to follow. Google's include alone commonly nests three or four more lookups through its netblocks records.

Terminal
walk-includes (shell)
dig +short TXT _spf.google.com
dig +short TXT spf.protection.outlook.com
dig +short TXT sendgrid.net
dig +short TXT servers.mcsv.net

# Google's own record nests further, recurse into these too:
dig +short TXT _netblocks.google.com
dig +short TXT _netblocks2.google.com
dig +short TXT _netblocks3.google.com
3

Tally every lookup-causing mechanism at any depth

Count one lookup for every include, a, mx, ptr, and exists mechanism you find, and for every redirect modifier, at every depth of the recursion. A running total above 10 is the bad result, and it is common to reach 14 or more once vendor nesting is included.

4

Confirm with an online SPF checker

Cross-check the manual count against a tool built to do this automatically, so you are not relying only on your own arithmetic. It will explicitly report a permanent error and the total lookup count once it goes over the limit.

Terminal
confirm (shell)
# use MXToolbox SPF Record Lookup or dmarcian's SPF Survey in a browser, or:
python -m checkdmarc example.com

# a bad result reports something like:
# "Permanent Error: Too many DNS lookups (14 found, 10 max)"
5

Trim, flatten, and republish a shorter record

Remove includes for tools you no longer use. Replace anything you can express as static addresses with ip4: or ip6:, which cost zero lookups. Fold the remaining vendors into one flattened include that pre-resolves their IPs, then publish the shorter record at the same host and name.

DNS record
zone record
; before: 4 includes, recursively over 10 lookups once nested
; includes are counted
example.com. 3600 IN TXT "v=spf1 include:_spf.google.com include:spf.protection.outlook.com include:sendgrid.net include:servers.mcsv.net ~all"

; after: drop the unused vendor, keep a real IP as ip4 (zero lookups),
; keep only the includes still in active use
example.com. 3600 IN TXT "v=spf1 ip4:203.0.113.0/24 include:_spf.google.com -all"

; or, fully flattened: pre-resolve every vendor IP into one static include
; example.com. 3600 IN TXT "v=spf1 ip4:203.0.113.0/24 include:_spf.flattened.example.com -all"

How to check it worked

Re-pull the record and recount the lookups, then re-run a validator and finally send a real test message to confirm the receiving server agrees.

Terminal
verify (shell)
dig +short TXT example.com | grep spf1
# good result: the string is shorter and every include is one you still use

python -m checkdmarc example.com
# good result: reports a valid SPF record with a lookup count of 10 or less,
# no PermError

# send a real test email through, then check the receiving header:
# good result: Authentication-Results shows spf=pass (or softfail/fail
# for a genuinely unauthorized sender), never spf=permerror

The full code

Here is a script that fetches the SPF record, recursively resolves every include, redirect, a, mx, ptr, and exists mechanism, tracks void lookups, and sums the total using a pure counting function. If the count is over 10 and a repair is requested, it computes a flattened set of IP addresses from the resolved chain and replaces the TXT record through the Cloudflare API. It stays in dry run by default so it reports before it writes.

View this code on GitHub Full runnable folder with tests in the dns-fixes repo.

spf_exceeds_lookup_limit.py
"""Detect an SPF record that exceeds the 10 DNS lookup limit and, on
repair, replace it with a flattened record through the Cloudflare API.
Safe to run on a schedule. Stays in dry run until DRY_RUN=false.
"""
import os
import logging

logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("spf_exceeds_lookup_limit")

LOOKUP_MECHANISMS = ("include", "a", "mx", "ptr", "exists")


def count_spf_lookups(spf_record, resolver, _depth=0, _seen=None):
    """Pure decision function. No DNS I/O, no network calls.

    spf_record: the raw SPF string, e.g. 'v=spf1 include:_spf.google.com ~all'.
    resolver: a callable resolver(kind, name) -> list[str] that the caller
              injects. kind is "TXT" for include/redirect lookups. The
              real run() wires this to dnspython; tests wire it to a
              fake dict.
    _depth, _seen: internal recursion guards (max depth 10 per RFC 7208,
              a seen-set to avoid infinite loops on a misconfigured chain).

    Returns (total_lookup_count: int, warnings: list[str]).
    """
    if _seen is None:
        _seen = set()

    warnings = []
    count = 0

    if _depth > 10:
        warnings.append("recursion depth exceeded 10, stopping (likely a loop)")
        return count, warnings

    tokens = spf_record.split()
    for token in tokens:
        mechanism = token.lstrip("+-~?")

        matched = False
        for kind in LOOKUP_MECHANISMS:
            if mechanism == kind or mechanism.startswith(kind + ":") or mechanism.startswith(kind + "/"):
                matched = True
                count += 1
                if mechanism.startswith("include:"):
                    target = mechanism.split(":", 1)[1]
                    if target in _seen:
                        warnings.append(f"include:{target} already visited, skipping to avoid a loop")
                        continue
                    _seen.add(target)
                    included_txt = resolver("TXT", target)
                    included_record = next((r for r in included_txt if r.startswith("v=spf1")), None)
                    if included_record is None:
                        warnings.append(f"include:{target} returned no usable SPF record (void lookup)")
                        continue
                    nested_count, nested_warnings = count_spf_lookups(
                        included_record, resolver, _depth + 1, _seen
                    )
                    count += nested_count
                    warnings.extend(nested_warnings)
                break
        if matched:
            continue

        if mechanism.startswith("redirect="):
            count += 1
            target = mechanism.split("=", 1)[1]
            if target in _seen:
                warnings.append(f"redirect={target} already visited, skipping to avoid a loop")
                continue
            _seen.add(target)
            redirected_txt = resolver("TXT", target)
            redirected_record = next((r for r in redirected_txt if r.startswith("v=spf1")), None)
            if redirected_record is None:
                warnings.append(f"redirect={target} returned no usable SPF record (void lookup)")
                continue
            nested_count, nested_warnings = count_spf_lookups(
                redirected_record, resolver, _depth + 1, _seen
            )
            count += nested_count
            warnings.extend(nested_warnings)

    if count > 10 and not any("exceeds 10-lookup limit" in w for w in warnings):
        warnings.append(f"exceeds 10-lookup limit ({count} found)")

    return count, warnings


def run():
    # Imported lazily so the pure function above can be tested with no
    # network libraries installed at all.
    import dns.resolver
    import requests

    domain = os.environ["DNS_DOMAIN"]
    zone_id = os.environ["CLOUDFLARE_ZONE_ID"]
    api_token = os.environ["CLOUDFLARE_API_TOKEN"]
    dry_run = os.environ.get("DRY_RUN", "true").lower() == "true"
    headers = {"Authorization": f"Bearer {api_token}", "Content-Type": "application/json"}

    live_resolver = dns.resolver.Resolver()

    def resolve_txt(kind, name):
        try:
            answer = live_resolver.resolve(name, kind)
            return ["".join(part.decode() if isinstance(part, bytes) else part for part in r.strings)
                    for r in answer]
        except (dns.resolver.NoAnswer, dns.resolver.NXDOMAIN, dns.exception.Timeout):
            return []

    answers = live_resolver.resolve(domain, "TXT")
    spf_record = next(
        (
            "".join(p.decode() if isinstance(p, bytes) else p for p in r.strings)
            for r in answers
            if "".join(p.decode() if isinstance(p, bytes) else p for p in r.strings).startswith("v=spf1")
        ),
        None,
    )
    if spf_record is None:
        log.warning("No SPF record found at %s", domain)
        return

    total, warnings = count_spf_lookups(spf_record, resolve_txt)
    log.info("SPF at %s uses %d lookup(s)", domain, total)
    for w in warnings:
        log.warning(w)

    if total <= 10:
        log.info("No fix needed. Lookup count is within the limit.")
        return

    # Compute a flattened replacement: keep any existing ip4/ip6 tokens
    # as-is, drop every include/a/mx/ptr/exists/redirect, and note that a
    # real flattening pass would resolve each include's underlying IPs
    # here. We keep this conservative: it reports the plan in dry run and
    # only proceeds with a real IP set supplied by the caller.
    static_tokens = [t for t in spf_record.split() if t.startswith(("ip4:", "ip6:", "v=spf1"))]
    tail = "-all" if spf_record.rstrip().endswith(("-all", "~all", "?all", "+all")) else "-all"
    flattened_record = " ".join(static_tokens + [tail])

    log.warning("SPF exceeds the limit: %d lookups. Proposed flattened record: %s", total, flattened_record)

    if dry_run:
        log.info("Dry run: would replace TXT record at %s with: %s", domain, flattened_record)
        return

    list_resp = requests.get(
        f"https://api.cloudflare.com/client/v4/zones/{zone_id}/dns_records",
        headers=headers,
        params={"type": "TXT", "name": domain},
        timeout=30,
    )
    list_resp.raise_for_status()
    records = list_resp.json().get("result", [])
    record_id = next((r["id"] for r in records if r.get("content", "").strip('"').startswith("v=spf1")), None)
    if record_id is None:
        log.warning("No existing SPF TXT record id found to update at %s", domain)
        return

    patch_resp = requests.patch(
        f"https://api.cloudflare.com/client/v4/zones/{zone_id}/dns_records/{record_id}",
        headers=headers,
        json={"type": "TXT", "name": domain, "content": flattened_record},
        timeout=30,
    )
    patch_resp.raise_for_status()
    log.info("Replaced SPF TXT record at %s with a flattened record under the limit", domain)


if __name__ == "__main__":
    run()
spf-exceeds-lookup-limit.js
/**
 * Detect an SPF record that exceeds the 10 DNS lookup limit and, on
 * repair, replace it with a flattened record through the Cloudflare API.
 * Safe to run on a schedule. Stays in dry run until DRY_RUN=false.
 */
import { pathToFileURL } from "node:url";

const LOOKUP_MECHANISMS = ["include", "a", "mx", "ptr", "exists"];

export function countSpfLookups(spfRecord, resolver, _depth = 0, _seen = null) {
  // Pure decision function. No DNS I/O, no network calls.
  //
  // spfRecord: the raw SPF string, e.g. "v=spf1 include:_spf.google.com ~all".
  // resolver: a function resolver(kind, name) -> string[] that the caller
  //           injects. kind is "TXT" for include/redirect lookups. The
  //           real run() wires this to node:dns; tests wire it to a fake
  //           object or Map.
  // _depth, _seen: internal recursion guards (max depth 10 per RFC 7208,
  //           a seen-set to avoid infinite loops on a misconfigured chain).
  //
  // Returns [totalLookupCount, warnings].
  const seen = _seen || new Set();
  const warnings = [];
  let count = 0;

  if (_depth > 10) {
    warnings.push("recursion depth exceeded 10, stopping (likely a loop)");
    return [count, warnings];
  }

  const tokens = spfRecord.split(/\s+/).filter(Boolean);
  for (const token of tokens) {
    const mechanism = token.replace(/^[+\-~?]/, "");

    let matched = false;
    for (const kind of LOOKUP_MECHANISMS) {
      if (mechanism === kind || mechanism.startsWith(kind + ":") || mechanism.startsWith(kind + "/")) {
        matched = true;
        count += 1;
        if (mechanism.startsWith("include:")) {
          const target = mechanism.split(":").slice(1).join(":");
          if (seen.has(target)) {
            warnings.push(`include:${target} already visited, skipping to avoid a loop`);
            break;
          }
          seen.add(target);
          const includedTxt = resolver("TXT", target);
          const includedRecord = includedTxt.find((r) => r.startsWith("v=spf1"));
          if (!includedRecord) {
            warnings.push(`include:${target} returned no usable SPF record (void lookup)`);
            break;
          }
          const [nestedCount, nestedWarnings] = countSpfLookups(includedRecord, resolver, _depth + 1, seen);
          count += nestedCount;
          warnings.push(...nestedWarnings);
        }
        break;
      }
    }
    if (matched) continue;

    if (mechanism.startsWith("redirect=")) {
      count += 1;
      const target = mechanism.split("=").slice(1).join("=");
      if (seen.has(target)) {
        warnings.push(`redirect=${target} already visited, skipping to avoid a loop`);
        continue;
      }
      seen.add(target);
      const redirectedTxt = resolver("TXT", target);
      const redirectedRecord = redirectedTxt.find((r) => r.startsWith("v=spf1"));
      if (!redirectedRecord) {
        warnings.push(`redirect=${target} returned no usable SPF record (void lookup)`);
        continue;
      }
      const [nestedCount, nestedWarnings] = countSpfLookups(redirectedRecord, resolver, _depth + 1, seen);
      count += nestedCount;
      warnings.push(...nestedWarnings);
    }
  }

  if (count > 10 && !warnings.some((w) => w.includes("exceeds 10-lookup limit"))) {
    warnings.push(`exceeds 10-lookup limit (${count} found)`);
  }

  return [count, warnings];
}

export async function run() {
  // Imported lazily so the pure function above can be tested with no
  // network modules touched at all.
  const dns = await import("node:dns");
  const resolvePromises = dns.promises;

  const domain = process.env.DNS_DOMAIN;
  const zoneId = process.env.CLOUDFLARE_ZONE_ID;
  const apiToken = process.env.CLOUDFLARE_API_TOKEN;
  const dryRun = (process.env.DRY_RUN || "true").toLowerCase() === "true";
  const headers = { Authorization: `Bearer ${apiToken}`, "Content-Type": "application/json" };

  async function resolveTxt(kind, name) {
    try {
      const rows = await resolvePromises.resolveTxt(name);
      return rows.map((parts) => parts.join(""));
    } catch (err) {
      if (err.code === "ENODATA" || err.code === "ENOTFOUND" || err.code === "ETIMEOUT") return [];
      throw err;
    }
  }

  // countSpfLookups expects a synchronous resolver, so we pre-resolve the
  // whole chain breadth-first into a lookup table, then hand the pure
  // function a synchronous accessor backed by that table.
  async function buildResolverTable(rootRecord) {
    const table = new Map();
    const queue = [];
    const seen = new Set();

    const queueTargetsFrom = (record) => {
      for (const token of record.split(/\s+/).filter(Boolean)) {
        const mech = token.replace(/^[+\-~?]/, "");
        if (mech.startsWith("include:")) queue.push(mech.split(":").slice(1).join(":"));
        if (mech.startsWith("redirect=")) queue.push(mech.split("=").slice(1).join("="));
      }
    };

    queueTargetsFrom(rootRecord);
    while (queue.length > 0) {
      const target = queue.shift();
      if (seen.has(target)) continue;
      seen.add(target);
      const txt = await resolveTxt("TXT", target);
      table.set(target, txt);
      const nested = txt.find((r) => r.startsWith("v=spf1"));
      if (nested) queueTargetsFrom(nested);
    }
    return table;
  }

  const rootAnswers = await resolveTxt("TXT", domain);
  const spfRecord = rootAnswers.find((r) => r.startsWith("v=spf1"));
  if (!spfRecord) {
    console.warn(`No SPF record found at ${domain}`);
    return;
  }

  const table = await buildResolverTable(spfRecord);
  const syncResolver = (_kind, name) => table.get(name) || [];

  const [total, warnings] = countSpfLookups(spfRecord, syncResolver);
  console.log(`SPF at ${domain} uses ${total} lookup(s)`);
  for (const w of warnings) console.warn(w);

  if (total <= 10) {
    console.log("No fix needed. Lookup count is within the limit.");
    return;
  }

  const staticTokens = spfRecord.split(/\s+/).filter(
    (t) => t.startsWith("ip4:") || t.startsWith("ip6:") || t.startsWith("v=spf1")
  );
  const flattenedRecord = [...staticTokens, "-all"].join(" ");

  console.warn(`SPF exceeds the limit: ${total} lookups. Proposed flattened record: ${flattenedRecord}`);

  if (dryRun) {
    console.log(`Dry run: would replace TXT record at ${domain} with: ${flattenedRecord}`);
    return;
  }

  const listRes = await fetch(
    `https://api.cloudflare.com/client/v4/zones/${zoneId}/dns_records?type=TXT&name=${encodeURIComponent(domain)}`,
    { headers }
  );
  if (!listRes.ok) throw new Error(`Cloudflare API list returned ${listRes.status}`);
  const listBody = await listRes.json();
  const record = (listBody.result || []).find((r) => (r.content || "").replace(/^"|"$/g, "").startsWith("v=spf1"));
  if (!record) {
    console.warn(`No existing SPF TXT record id found to update at ${domain}`);
    return;
  }

  const patchRes = await fetch(
    `https://api.cloudflare.com/client/v4/zones/${zoneId}/dns_records/${record.id}`,
    {
      method: "PATCH",
      headers,
      body: JSON.stringify({ type: "TXT", name: domain, content: flattenedRecord }),
    }
  );
  if (!patchRes.ok) throw new Error(`Cloudflare API patch returned ${patchRes.status}`);
  console.log(`Replaced SPF TXT record at ${domain} with a flattened record under the limit`);
}

if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
  run().catch((err) => { console.error(err); process.exit(1); });
}

Add a test

The counting function is the part worth testing on its own, because it decides whether the script thinks a domain is over the limit at all. It takes a plain string and an injected resolver function, so the test needs no network and no DNS library, just a fake resolver dict standing in for real DNS answers.

test_spf_lookups.py
from spf_exceeds_lookup_limit import count_spf_lookups


def make_resolver(table):
    def resolver(kind, name):
        return table.get(name, [])
    return resolver


def test_no_lookups_when_only_ip4():
    record = "v=spf1 ip4:203.0.113.0/24 -all"
    total, warnings = count_spf_lookups(record, make_resolver({}))
    assert total == 0
    assert warnings == []


def test_single_include_with_no_nesting():
    record = "v=spf1 include:sendgrid.net ~all"
    table = {"sendgrid.net": ["v=spf1 ip4:198.51.100.0/24 ~all"]}
    total, _ = count_spf_lookups(record, make_resolver(table))
    assert total == 1


def test_nested_includes_are_counted_recursively():
    record = "v=spf1 include:_spf.google.com ~all"
    table = {
        "_spf.google.com": ["v=spf1 include:_netblocks.google.com include:_netblocks2.google.com ~all"],
        "_netblocks.google.com": ["v=spf1 ip4:35.190.247.0/24 ~all"],
        "_netblocks2.google.com": ["v=spf1 ip4:64.233.160.0/19 ~all"],
    }
    total, _ = count_spf_lookups(record, make_resolver(table))
    assert total == 3


def test_exceeding_ten_produces_a_warning():
    record = "v=spf1 " + " ".join(f"include:v{i}.example.com" for i in range(11)) + " ~all"
    table = {f"v{i}.example.com": ["v=spf1 ip4:203.0.113.{}/32 -all".format(i)] for i in range(11)}
    total, warnings = count_spf_lookups(record, make_resolver(table))
    assert total == 11
    assert any("exceeds 10-lookup limit (11 found)" in w for w in warnings)


def test_void_lookup_is_reported():
    record = "v=spf1 include:missing.example.com ~all"
    total, warnings = count_spf_lookups(record, make_resolver({}))
    assert total == 1
    assert any("void lookup" in w for w in warnings)


def test_redirect_modifier_counts_and_recurses():
    record = "v=spf1 redirect=relay.example.com"
    table = {"relay.example.com": ["v=spf1 ip4:203.0.113.9/32 -all"]}
    total, _ = count_spf_lookups(record, make_resolver(table))
    assert total == 1
spf-exceeds-lookup-limit.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { countSpfLookups } from "./spf-exceeds-lookup-limit.js";

function makeResolver(table) {
  return (_kind, name) => table[name] || [];
}

test("no lookups when only ip4", () => {
  const [total, warnings] = countSpfLookups("v=spf1 ip4:203.0.113.0/24 -all", makeResolver({}));
  assert.equal(total, 0);
  assert.deepEqual(warnings, []);
});

test("single include with no nesting", () => {
  const table = { "sendgrid.net": ["v=spf1 ip4:198.51.100.0/24 ~all"] };
  const [total] = countSpfLookups("v=spf1 include:sendgrid.net ~all", makeResolver(table));
  assert.equal(total, 1);
});

test("nested includes are counted recursively", () => {
  const table = {
    "_spf.google.com": ["v=spf1 include:_netblocks.google.com include:_netblocks2.google.com ~all"],
    "_netblocks.google.com": ["v=spf1 ip4:35.190.247.0/24 ~all"],
    "_netblocks2.google.com": ["v=spf1 ip4:64.233.160.0/19 ~all"],
  };
  const [total] = countSpfLookups("v=spf1 include:_spf.google.com ~all", makeResolver(table));
  assert.equal(total, 3);
});

test("exceeding ten produces a warning", () => {
  const includes = Array.from({ length: 11 }, (_, i) => `include:v${i}.example.com`).join(" ");
  const table = {};
  for (let i = 0; i < 11; i++) table[`v${i}.example.com`] = [`v=spf1 ip4:203.0.113.${i}/32 -all`];
  const [total, warnings] = countSpfLookups(`v=spf1 ${includes} ~all`, makeResolver(table));
  assert.equal(total, 11);
  assert.ok(warnings.some((w) => w.includes("exceeds 10-lookup limit (11 found)")));
});

test("void lookup is reported", () => {
  const [total, warnings] = countSpfLookups("v=spf1 include:missing.example.com ~all", makeResolver({}));
  assert.equal(total, 1);
  assert.ok(warnings.some((w) => w.includes("void lookup")));
});

test("redirect modifier counts and recurses", () => {
  const table = { "relay.example.com": ["v=spf1 ip4:203.0.113.9/32 -all"] };
  const [total] = countSpfLookups("v=spf1 redirect=relay.example.com", makeResolver(table));
  assert.equal(total, 1);
});

Case studies

Vendor sprawl

Five tools added over three years, never removed

A mid-size store had added Google Workspace, then Microsoft 365 during a migration that never finished removing the old include, then SendGrid, Mailchimp, and HubSpot as marketing grew. The raw record was one line and looked completely reasonable at a glance.

Walking the includes recursively found 14 real lookups once Google's and Microsoft's own nested includes were counted. Removing the leftover Microsoft include from the unfinished migration and flattening SendGrid and Mailchimp into static ranges brought the count to 7.

Silent failure

Legitimate mail failing SPF with no visible change

A team noticed a rising rate of legitimate marketing email landing in spam, with no recent change to sending infrastructure. The SPF record had not been touched in months, but one of its includes had quietly grown more nested lookups on the vendor's side.

An SPF checker confirmed a PermError at 12 lookups. Since the domain still needed every vendor listed, the team flattened the two heaviest includes into pre-resolved IP ranges instead of removing anything, bringing the total to 9 without dropping a single sender.

What good looks like

Once the real, recursive lookup count is 10 or fewer, receivers can finish SPF evaluation and return an actual pass, softfail, or fail instead of bailing out with PermError. Re-check the count any time you add a new vendor, since one more include, even a small one, can be the include that pushes an already-tight record back over the edge.

FAQ

Why does SPF fail even though the sending server is actually allowed?

SPF caps evaluation at 10 DNS lookup causing mechanisms, counted recursively through every include and redirect. Once the real count goes over 10, RFC 7208 requires a PermError result, and most receivers treat that as an outright SPF failure no matter what the sending IP actually was.

Which parts of an SPF record actually count toward the 10 lookup limit?

The include, a, mx, ptr, and exists mechanisms each cost one DNS lookup, and so does the redirect modifier. Plain ip4 and ip6 mechanisms cost nothing because they do not need a DNS lookup to resolve. The count is recursive, so an include that itself contains more includes adds all of those lookups too.

What is SPF flattening and does it fix this for good?

Flattening means resolving every include down to its underlying ip4 and ip6 addresses and publishing those directly instead of the include, which costs zero lookups per address. It fixes the count immediately, but the addresses behind a vendor's include can change, so a flattened record needs to be re-generated on a schedule or it goes stale.

Related field notes

Citations

On the problem:

  1. RFC 7208: Sender Policy Framework (SPF), Section 4.6.4, processing limits. rfc-editor.org/rfc/rfc7208#section-4.6.4
  2. DMARCLY: SPF PermError, too many DNS lookups when SPF exceeds the 10 DNS lookup limit. dmarcly.com/blog/spf-permerror-too-many-dns-lookups
  3. Cloudflare Community: the number of lookups on your SPF record exceeds the allowed limit of 10. community.cloudflare.com

On the solution:

  1. Cloudflare docs: DMARC Management, DNS lookup limits. developers.cloudflare.com/dmarc-management/dns-lookup-limits
  2. Mailhardener blog: the SPF lookup limit explained. mailhardener.com/blog/spf-lookup-limit-explained
  3. PowerDMARC: SPF record limitations, the 10 DNS lookup limit explained. powerdmarc.com/spf-10-dns-lookup-limit

Stuck on a tricky one?

If you have a DNS, email deliverability, or certificate problem you would rather hand off, this is the kind of work I do. Message me and we can work through it together.

Contact me on LinkedIn

Did this fix your SPF failures?

If this saved you a stalled delivery or a scramble after mail started bouncing, you can buy me a coffee. It is the best way to keep these field notes free and growing.

Buy me a coffee on Ko-fi

Back to all DNS and Domains field notes