SPF flattening: how to work around the 10 DNS lookup limit
SPF has an architectural constraint that most people discover at the worst possible time: a hard cap of 10 DNS lookups per check. Add Google Workspace, SendGrid, HubSpot, Zendesk, and Intercom and you're already at the limit. The eleventh include breaks the entire record. Messages stop passing SPF. Flattening fixes the problem, but introduces new ones.
Where the 10-lookup limit comes from
RFC 7208 (the SPF specification) sets a hard rule: when processing an SPF record, the receiving server must not perform more than 10 DNS lookups that involve name resolution. This counts the include, a, mx, redirect and exists mechanisms. ip4 and ip6 are excluded since they require no additional DNS queries.
The limit was designed to prevent amplification attacks. Without it, an attacker could craft an SPF record with a hundred nested includes, turning each message check into a hundred DNS queries and enabling DDoS through DNS.
The problem is that the spec was written when a company sent mail from one or two servers. Today the average business relies on 5-8 sending services, each requiring its own include, and each include may contain more includes of its own.
How DNS lookups are counted
A common mistake is counting only the top-level includes. The count is recursive. Take this record:
v=spf1 include:_spf.google.com # 1 lookup include:sendgrid.net # 1 lookup include:spf.protection.outlook.com # 1 lookup -all
Three includes, three lookups? Not quite. Look inside _spf.google.com:
$ dig TXT _spf.google.com +short
"v=spf1 include:_netblocks.google.com
include:_netblocks2.google.com
include:_netblocks3.google.com ~all"One Google include costs four lookups: _spf.google.com itself plus the three nested netblocks. SendGrid adds another 1-3. Outlook another 2-4. Three lines in your SPF record can easily consume 8-11 of the 10 allowed lookups.
To check your current count, use an online tool or the CLI:
# MXToolbox # https://mxtoolbox.com/spf.aspx # Or the spfcheck CLI (Python) pip install pyspf spfquery -ip 1.2.3.4 -sender user@yourdomain.com -helo yourdomain.com
What happens when you exceed the limit
The SPF result becomes permerror. Not softfail, not neutral: permanent error. The receiving server cannot produce an SPF verdict at all. In practice, most providers treat permerror as a failed check.
The downstream consequences: DMARC alignment via SPF fails. If DKIM is also broken or missing, the message fails DMARC entirely. Gmail, Yahoo, and Outlook will all react badly. Best case, messages land in spam. Worst case, rejection at the SMTP level.
Based on uChecker data, about 15% of domains that pass through our checker have SPF records that exceed the lookup limit. Owners often have no idea, because DKIM keeps working and partially compensates for the SPF failure.
What SPF flattening is
The idea is simple: instead of nested includes that trigger a chain of DNS lookups, you substitute the final IP addresses directly. ip4 and ip6 do not count against the limit. You unroll the include tree into a flat list of IP ranges, and the limit stops being a problem.
Before flattening:
v=spf1 include:_spf.google.com include:sendgrid.net
include:mail.zendesk.com include:spf.mandrillapp.com
include:servers.mcsv.net -all
# Total: 12-14 DNS lookups. SPF is broken.After flattening:
v=spf1 ip4:35.190.247.0/24 ip4:64.233.160.0/19
ip4:66.102.0.0/20 ip4:66.249.80.0/20
ip4:72.14.192.0/18 ip4:74.125.0.0/16
ip4:108.177.8.0/21 ip4:173.194.0.0/16
ip4:209.85.128.0/17 ip4:216.58.192.0/19
ip4:216.239.32.0/19
ip4:167.89.0.0/17 ip4:168.245.0.0/17
... -all
# Total: 0 DNS lookups. SPF is valid.Looks like it solves everything. But there are catches.
Pitfalls of flattening
1. IP addresses change
Google, SendGrid, Mailgun, and other providers periodically add, remove, and reassign IP addresses for their mail servers. Flatten your record once and forget it, and within a month or two some IPs go stale while new ones never make it into your record. Mail sent from those new IPs fails SPF.
This is the main risk. Flattening converts a dynamic system (includes resolve fresh on every check) into a static snapshot. Snapshots rot.
2. TXT record length
A single DNS TXT record is limited to 255 characters per string. DNS servers let you concatenate multiple strings into one record, but total length is still bounded, typically 4096 characters. A fully unrolled SPF record for a larger company can easily blow past that. The solution is to split across subdomains (more on that below).
3. Loss of transparency
A record with includes is readable. You can see: Google, SendGrid, Zendesk. A record with dozens of CIDR ranges tells you nothing. Six months later you won't remember which IPs belong to Zendesk and which to SendGrid. Debugging becomes painful.
Option 1: manual flattening
Works for small teams with 2-3 sending services, and only if you are willing to verify IP accuracy once a month. The process:
- For each include, recursively resolve all IP ranges.
- Replace the includes with
ip4/ip6entries in the SPF record. - If the record gets too long, split it across subdomains.
- Set a reminder to verify the IPs every 2-4 weeks.
A bash script to extract IPs from an include:
#!/bin/bash
# resolve-spf.sh — recursively extracts IPs from an SPF record
# Usage: ./resolve-spf.sh _spf.google.com
resolve_spf() {
local domain="$1"
local record
record=$(dig TXT "$domain" +short | tr -d '"' | tr ' ' '\n')
for token in $record; do
case "$token" in
include:*)
resolve_spf "${token#include:}"
;;
ip4:*|ip6:*)
echo "$token"
;;
a|a:*)
# The 'a' mechanism also consumes a DNS lookup —
# resolve it to an IP here
local host="${token#a:}"
[ "$host" = "a" ] && host="$domain"
dig A "$host" +short | while read -r ip; do
echo "ip4:$ip"
done
;;
esac
done
}
echo "# IPs for: $1"
resolve_spf "$1" | sort -u$ ./resolve-spf.sh _spf.google.com # IPs for: _spf.google.com ip4:35.190.247.0/24 ip4:64.233.160.0/19 ip4:66.102.0.0/20 ip4:66.249.80.0/20 ip4:72.14.192.0/18 ip4:74.125.0.0/16 ip4:108.177.8.0/21 ip4:130.211.0.0/22 ip4:172.217.0.0/19 ip4:173.194.0.0/16 ip4:209.85.128.0/17 ip4:216.58.192.0/19 ip4:216.239.32.0/19 ip6:2001:4860:4000::/36 ip6:2404:6800:4000::/36 ip6:2607:f8b0:4000::/36 ip6:2800:3f0:4000::/36 ip6:2a00:1450:4000::/36 ip6:2c0f:fb50:4000::/36
The problem is obvious: Google alone is 19 entries. Add SendGrid, Mailchimp, and HubSpot and the TXT record becomes unmanageable. You need a more structured approach.
Option 2: splitting across subdomains
Instead of one giant record, create several SPF records on subdomains and reference them from the root. Each include pointing to your own subdomain costs one DNS lookup, but inside that subdomain you use only ip4/ ip6, which do not count.
# Root record — yourdomain.com
v=spf1 include:_spf1.yourdomain.com
include:_spf2.yourdomain.com -all
# 2 DNS lookups
# _spf1.yourdomain.com — Google + SendGrid (IPs only)
v=spf1 ip4:35.190.247.0/24 ip4:64.233.160.0/19
ip4:66.102.0.0/20 ip4:66.249.80.0/20
ip4:72.14.192.0/18 ip4:74.125.0.0/16
ip4:167.89.0.0/17 ip4:168.245.0.0/17 -all
# _spf2.yourdomain.com — HubSpot + Zendesk (IPs only)
v=spf1 ip4:192.92.128.0/21 ip4:185.28.196.0/22
ip4:104.16.0.0/14 ip4:192.161.144.0/20 -allResult: 2 DNS lookups instead of 12-14. Each subdomain holds only IP ranges. The structure is readable: one subdomain per group of services, easy to update independently.
Option 3: automated flattening
Manual maintenance does not scale. With more than three sending services you need automation: a script or service that regularly resolves all includes, diffs against the current record, and updates DNS when things change.
A Python script for automated flattening via the Cloudflare API:
import dns.resolver
import requests
import hashlib
CF_API = "https://api.cloudflare.com/client/v4"
CF_TOKEN = "YOUR_TOKEN"
ZONE_ID = "YOUR_ZONE_ID"
DOMAIN = "yourdomain.com"
# Services whose includes should be flattened
INCLUDES = [
"_spf.google.com",
"sendgrid.net",
"mail.zendesk.com",
"spf.mandrillapp.com",
]
def resolve_ips(domain: str, seen: set = None) -> list[str]:
"""Recursively extracts ip4/ip6 from an SPF record."""
if seen is None:
seen = set()
if domain in seen:
return []
seen.add(domain)
ips = []
try:
answers = dns.resolver.resolve(domain, "TXT")
except Exception:
return []
for rdata in answers:
txt = rdata.to_text().strip('"')
for token in txt.split():
if token.startswith("ip4:") or token.startswith("ip6:"):
ips.append(token)
elif token.startswith("include:"):
ips.extend(resolve_ips(token[8:], seen))
return ips
def build_flattened_record() -> str:
all_ips = []
for inc in INCLUDES:
all_ips.extend(resolve_ips(inc))
unique = sorted(set(all_ips))
return f"v=spf1 {' '.join(unique)} -all"
def update_cloudflare(record_content: str):
headers = {
"Authorization": f"Bearer {CF_TOKEN}",
"Content-Type": "application/json",
}
# Fetch current record
r = requests.get(
f"{CF_API}/zones/{ZONE_ID}/dns_records"
f"?type=TXT&name={DOMAIN}",
headers=headers,
)
records = r.json()["result"]
spf_record = next(
(rec for rec in records if rec["content"].startswith("v=spf1")),
None,
)
if not spf_record:
print("SPF record not found")
return
if spf_record["content"] == record_content:
print("No changes")
return
# Update
requests.put(
f"{CF_API}/zones/{ZONE_ID}/dns_records/{spf_record['id']}",
headers=headers,
json={
"type": "TXT",
"name": DOMAIN,
"content": record_content,
},
)
print(f"SPF updated: {record_content[:80]}...")
if __name__ == "__main__":
flat = build_flattened_record()
print(f"IP ranges collected: {flat.count('ip4:') + flat.count('ip6:')}")
update_cloudflare(flat)Run it via cron once a day. The script detects changes and updates the record automatically. Add a Slack or email notification on each update so you know when a provider rotates IPs.
# crontab -e 0 6 * * * /usr/bin/python3 /opt/scripts/spf-flatten.py >> /var/log/spf-flatten.log 2>&1
Managed services for SPF flattening
If you would rather not write and maintain scripts yourself, there are dedicated tools. They monitor source includes, update DNS, and send alerts when things change.
- AutoSPF / SPF Optimizer - you delegate SPF to their subdomain and they maintain a current flat IP list automatically. Minimal effort, but you take on a third-party dependency.
- dmarcian SPF Flattening - built into their DMARC management platform. If you already use dmarcian for DMARC reports, keeping everything in one place makes sense.
- EasyDMARC - similar feature set plus monitoring and alerts.
- PowerSPF (from PowerDMARC) - dynamic flattening using SPF macros. Instead of IP addresses it uses the
existsmechanism to check against their servers. One DNS lookup instead of ten.
The right choice depends on your tolerance for third-party dependencies. For critical infrastructure, a self-hosted script with monitoring is safer. For most businesses, a paid service with an SLA is the practical option.
Alternative: SPF macros
Less widely known but powerful. SPF supports macros: variables substituted at check time. The exists mechanism lets you offload the check to an external DNS server you control:
v=spf1 exists:%{i}._spf.yourdomain.com -allHere %{i} is replaced with the sender's IP address. The receiving server makes an A query to 1.2.3.4._spf.yourdomain.com. If the record exists (returns any A response), SPF passes. One DNS lookup. You manage the _spf.yourdomain.com zone and create A records for each authorized IP.
PowerDMARC, ValiMail, and some larger companies with their own DNS infrastructure use this approach. The main drawback: it requires a separate DNS zone with automated management, which not every DNS provider handles conveniently.
Checklist: flattening without shooting yourself in the foot
- Count your current DNS lookups. MXToolbox or
dmarcian.com/spf-survey. If you are under 10, flattening is unnecessary. Do not fix what is working. - Map your includes. Write down which service sits behind each include. You will need this when debugging later.
- Choose your approach: manual, scripted, or managed. Manual does not work for 4+ services.
- Test before deploying. Send test messages through every channel and verify SPF pass in the headers.
- Set up monitoring. Check SPF status daily. DMARC aggregate reports (rua) will surface failures before your open rate drops noticeably.
- Keep DKIM in place. Flattening does not replace DKIM. Even with a perfect SPF record, DKIM stays the second factor for DMARC alignment and the only way to verify message integrity.
- Document your changes. A year from now you will not remember why those specific IP ranges are in the record. Keep a mapping of "IP range to service" alongside the configuration.
When flattening is not necessary
Before adding infrastructure complexity, check whether a simpler fix will do.
- Remove stale includes. Dropped Mailchimp six months ago but the include is still there? Delete it. Every unused include wastes lookup budget for nothing.
- Use subdomains for different sending channels. Marketing sends from
mail.yourdomain.com, transactional fromnotify.yourdomain.com. Each subdomain gets its own SPF record and its own 10-lookup budget. - Confirm DKIM is working. If DKIM is configured correctly for all sending channels, a failing SPF result is not fatal for DMARC. DMARC only requires one of the two to pass. Flattening for its own sake makes no sense when DKIM already covers everything.
Summary
SPF flattening is a tool, not a silver bullet. It solves one specific technical problem: too many DNS lookups. But it creates a new one: keeping IP addresses current. Match the approach to your scale. Up to 3 services, subdomains are enough. Four to six, use a script with cron. More than that, pay for a managed service with automatic updates.
And remember: SPF is one piece of the deliverability puzzle. A flawless SPF record will not save you if a third of your list is invalid addresses. Bounce rate destroys domain reputation faster than any permerror.
Got SPF sorted? Check the list next. Hard bounces, spam traps, and dead addresses hurt your reputation just as much as a broken SPF record. Upload your list to uChecker and see the real health of your database in minutes, before your next send.
