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.
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.
Why it happens
- Each new email tool gets its own
include:added over time: Google Workspace, Microsoft 365, SendGrid, Mailchimp, HubSpot, a helpdesk tool, a survey tool, and nobody ever removes the old ones. - Vendor includes are rarely one lookup each. Google's
include:_spf.google.comnests further into_netblocks.google.com,_netblocks2.google.com, and_netblocks3.google.com, adding three or four lookups by itself. - The count is recursive and cumulative across the whole tree, not per include, so five includes that look modest on their own can add up to 15 or more real lookups once every nested include is walked.
- Old marketing tools or decommissioned mail relays are left in the record long after the store stopped using them, quietly still costing lookups.
- Nobody actually counts the lookups by reading the raw record, because the record itself still looks short and reasonable at a glance.
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.
How to fix it
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.
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"
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.
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
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.
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.
# 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)"
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.
; 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.
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.
"""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()
/**
* 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.
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
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
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.
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.
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:
- RFC 7208: Sender Policy Framework (SPF), Section 4.6.4, processing limits. rfc-editor.org/rfc/rfc7208#section-4.6.4
- DMARCLY: SPF PermError, too many DNS lookups when SPF exceeds the 10 DNS lookup limit. dmarcly.com/blog/spf-permerror-too-many-dns-lookups
- Cloudflare Community: the number of lookups on your SPF record exceeds the allowed limit of 10. community.cloudflare.com
On the solution:
- Cloudflare docs: DMARC Management, DNS lookup limits. developers.cloudflare.com/dmarc-management/dns-lookup-limits
- Mailhardener blog: the SPF lookup limit explained. mailhardener.com/blog/spf-lookup-limit-explained
- 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.
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