SPF in one line#
SPF tells receiving servers which IPs (or which other domains' SPF records) are authorized to send from your domain. Published as a TXT record at the apex domain.
v=spf1 include:amazonses.com -all
Translation: "Allow Amazon SES to send for me; REJECT everything else."
The mechanisms#
| Mechanism |
Meaning |
Example |
ip4:1.2.3.4 |
Authorize this specific IPv4 |
ip4:54.240.0.10 |
ip4:1.2.3.0/24 |
Authorize this CIDR |
ip4:1.2.3.0/24 |
ip6:... |
Authorize IPv6 |
ip6:2001:db8::1 |
include:other.com |
Pull in other.com's SPF record |
include:amazonses.com |
a |
Authorize this domain's A record IP |
a (the domain's own A) |
a:mail.brand.com |
Authorize a specific subdomain's A record |
a:mail.brand.com |
mx |
Authorize the MX records' IPs |
mx |
exists:%{i}.spamcheck.brand.com |
Macro-based check (rarely used) |
(advanced) |
The qualifiers (before each mechanism)#
| Qualifier |
Action |
+ (or none) |
PASS — authorized |
~ |
SOFTFAIL — receiver-discretion (typically goes to spam) |
- |
FAIL — REJECT |
? |
NEUTRAL — no policy stated |
The all at the end is the catch-all: -all (hard fail) is the most strict; ~all (soft fail) is conservative for new setups.
v=spf1 ip4:1.2.3.4 ~all
# Allows 1.2.3.4; soft-fail everything else (likely spam folder)
v=spf1 ip4:1.2.3.4 -all
# Allows 1.2.3.4; HARD-FAIL everything else (rejected)
Use ~all until production stable, then switch to -all.
The 10-lookup limit#
SPF lookups are recursive: include:amazonses.com triggers a DNS lookup for amazonses.com's SPF record, which may include further records, recursively.
RFC 7208 caps recursive lookups at 10 per evaluation. Beyond 10, receivers return PermError and the SPF check fails.
Each of these counts as 1 lookup:
- Every
include: (and the includes inside it count too, recursively)
- Every
a and mx
- Every
ptr (deprecated)
- Every
exists
ip4: and ip6: do NOT count (no DNS lookup needed).
Multi-vendor blowup example:
v=spf1 include:amazonses.com include:_spf.mailgun.org include:sendgrid.net include:spf.mtasv.net -all
Each include resolves to ~3-4 sub-includes:
amazonses.com → 1 (amazonses.com itself) + 1 (sub-include) = 2 lookups
_spf.mailgun.org → 1 + 2 = 3 lookups
sendgrid.net → 1 + 4 = 5 lookups
spf.mtasv.net → 1 + 1 = 2 lookups
Total: 2 + 3 + 5 + 2 = 12 lookups → SPF PermError → ALL your mail fails SPF check.
SPF flattening (the fix at scale)#
Flattening = pre-resolve all includes + replace with IPs in your published record:
# Before (might exceed 10):
v=spf1 include:amazonses.com include:_spf.mailgun.org include:sendgrid.net -all
# After (flattened):
v=spf1 ip4:54.240.0.0/18 ip4:13.32.0.0/15 ip4:54.240.36.0/22 ip4:104.197.0.0/16 ... -all
Trade-off:
- ✅ No 10-lookup issue
- ✅ Faster receiver-side check
- ❌ When vendors update IP ranges, your flattened record becomes stale
- ❌ Without automated re-flattening, you'll silently fail SPF when ranges shift
Tools that auto-flatten daily:
- ValiMail SPF — paid; handles re-publishing via your DNS host's API
- EasySPF — paid; similar
- DMARCLY SPF — included in their DMARC platform
- Manual script + cron — DIY, possible but high-maintenance
For 1-2 vendor setups, manual is fine. 3+ vendors, use a tool.
Verify in AcelleMail#
Open the sending-domain detail#
In AcelleMail's sidebar, Sending → Sending domains. The list shows every domain you've registered with status chips (Verified / Pending / Failed) and per-auth indicators:

Click into your domain row. The detail page surfaces exactly which DNS records to publish (TXT for SPF, CNAMEs for DKIM, TXT for DMARC) with copy-paste-ready values + current verification state per check:

The DKIM CNAME and DMARC TXT records are visible alongside SPF. The detail page shows live verification status — re-run with the Verify domain button after publishing changes.
Common SPF errors + fixes#
| Error |
Cause |
Fix |
| PermError (10-lookup exceeded) |
Too many includes |
Flatten via tool; OR remove unused vendor includes |
| TempError (DNS timeout) |
DNS resolver couldn't reach one of the includes |
Usually self-resolves; persistent = check vendor's DNS health |
| Fail / Hard fail |
Sending IP not in your SPF record |
Add the vendor's include: OR ip4: |
| None (no SPF record published) |
Missing TXT record |
Publish per Complete DNS setup |
| Softfail intentional but mail goes to spam |
Some receivers treat ~all as suspicious |
Switch to -all after production-stable |
| Pass but DMARC still fails |
SPF + DKIM + DMARC alignment issue |
Check envelope-sender domain matches From: |
Real-world SPF records#
# Small sender, single vendor
v=spf1 include:amazonses.com -all
# Multi-vendor mid-size
v=spf1 include:amazonses.com include:_spf.mailgun.org -all
# Large enterprise with corporate-mail mixed in
v=spf1 include:_spf.google.com include:amazonses.com include:_spf.mailgun.org include:sendgrid.net -all
# (4 includes — close to 10-lookup limit; consider flattening)
# Self-hosted Postfix on a fixed IP
v=spf1 ip4:1.2.3.4 -all
# Hybrid: self-hosted + transactional via SES
v=spf1 ip4:1.2.3.4 include:amazonses.com -all
What SPF doesn't do#
SPF alone is NOT sufficient for deliverability:
| Concern |
SPF's role |
Other records |
| Sender authentication |
✅ (per-IP) |
DKIM (per-message), DMARC (alignment) |
| Message integrity |
❌ |
DKIM (cryptographic signature) |
| Phishing protection |
Partial |
DKIM + DMARC together |
| BIMI logo display |
Required base |
DKIM + DMARC + BIMI |
SPF is necessary but not sufficient. Always pair with DKIM + DMARC.
Advanced: SPF macro syntax + multi-domain alignment + per-vendor flattening tools
SPF macros (rarely needed, mentioned for completeness):
v=spf1 exists:%{i}.spamcheck.brand.com -all
Macro %{i} expands to the sender IP. The exists mechanism checks if that DNS name resolves. Used for per-IP spam-list integration. Almost never needed for AcelleMail use cases.
Other macros:
%{s} — sender (envelope-sender)
%{l} — local-part of envelope-sender
%{d} — domain
%{p} — sender's PTR record (deprecated due to performance)
%{h} — HELO domain
Multi-domain alignment (for subdomain senders):
Apex (brand.com): v=spf1 include:amazonses.com -all
Subdomain (mail.brand.com): v=spf1 include:amazonses.com -all
Each subdomain needs its own SPF record. Don't rely on the apex to apply to subdomains automatically (DNS doesn't work that way for TXT records).
Per-vendor SPF flattening tools:
ValiMail SPF — $50-100/month; vendor-agnostic
EasySPF — $20-50/month; basic
DMARCLY SPF — included with DMARC tier
PowerDMARC SPF — included with DMARC tier
For self-managed flattening, the script:
#!/bin/bash
# Daily SPF flatten + republish
# Replace IP ranges from vendor's published SPF includes
current_spf=$(dig TXT yourdomain.com +short | grep "v=spf1")
new_includes=""
for vendor in amazonses.com _spf.mailgun.org sendgrid.net; do
ips=$(dig TXT $vendor +short | grep -oE 'ip4:[0-9./]+|ip6:[0-9a-f:/]+')
new_includes="$new_includes $ips"
done
new_spf="v=spf1$new_includes -all"
echo "Updating SPF: $new_spf"
# Update via Cloudflare API (adjust per your DNS host)
curl -X PATCH "https://api.cloudflare.com/client/v4/zones/$ZONE_ID/dns_records/$RECORD_ID" \
-H "Authorization: Bearer $CF_TOKEN" \
-d "{\"type\":\"TXT\",\"name\":\"yourdomain.com\",\"content\":\"$new_spf\"}"
Run as a daily cron. Manual fallback when tool subscriptions feel expensive.
SPF compliance audit:
# Daily check on all your sending domains
domains=$(curl -sH "Authorization: Bearer $TOKEN" \
"https://acellemail.com/api/v1/admin/sending-domains" | jq -r '.data[].name')
for d in $domains; do
spf=$(dig TXT $d +short | grep "v=spf1")
if [ -z "$spf" ]; then
echo "🚨 $d: SPF MISSING"
continue
fi
lookups=$(echo $spf | grep -oE "include:|a:|mx" | wc -l)
if [ $lookups -gt 8 ]; then
echo "⚠️ $d: high lookup count ($lookups) — risk of PermError"
fi
done
SPF macros for compliance reporting:
For per-customer SPF visibility in SaaS context:
v=spf1 include:_customer.%{d}.spf.acellemail.com -all
Per-customer SPF macro that resolves dynamically. Advanced; typically operator-managed by AcelleMail SaaS providers.
Related articles#