SPF Record Deep Dive — Mechanisms, Limits, and Edge Cases

SPF looks simple: list your sending IPs. The real-world version: 10-lookup limits, include-chains, the difference between -all and ~all, SPF flattening at scale. This is the operator's deep dive.

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:

Sending domains list

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:

Sending-domain detail — DNS records + auth status

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

11 comentarios

6 comentarios

  1. bos.devops
    Our DKIM rotation broke for 2 days because we updated the active selector first, then waited to delete the old. Should be the other way — publish new, wait 48h for cache, switch sending, THEN remove old.
  2. tranminh.devop…
    If you use Vercel or Netlify for the apex, watch out — they sometimes override TXT records via their auto-DNS feature. Bit us once with a stripped SPF record.
  3. lequan.saigon
    worth noting: our DNS provider (Cloudflare) caches negative responses for 1 hour. We added a TXT record, dig showed it, but mail-tester said missing for another 40 minutes. Almost lost our minds. TTL was set to 300 but the parent zone NS cache held.
  4. femi.adeyemi
    How do you handle DNS for clients in white-label setups? The customer would need to add records to their domain — is there a clean way to bulk-verify those?
    1. admin
      we're aware of the silent-bail-out on deleted customers — there's an open issue for it. Workaround for now: monitor the campaign:rerun log for absence of expected log lines, alert when silent for > 20 min.
  5. priya.iyer.ops
    Quick question: do receivers actually enforce the SPF -all hard fail, or do most just downrate? I've heard mixed things and I'm hesitant to switch from ~all
    1. admin (editado)
      We don't recommend that approach in production. It works in dev but has subtle race conditions under concurrent load. Stick with the documented pattern.
    2. admin (editado)
      Honest answer: it depends on your provider. SES handles it gracefully; Mailgun is stricter. We'll add a provider-by-provider table in the next revision.
  6. aisha.khan.pak
    What's your recommendation for sub-domains? We send from mail.example.com AND notifications.example.com. Same DKIM selector or separate?
    1. admin
      Same answer as above for SaaS-tenant — works the same way per-tenant, with the caveat that the cron must be set per-customer (not just system-wide)
    2. admin (editado)
      Right — for RDS specifically, you can change wait_timeout via the parameter group without a reboot if it's set as 'dynamic'. Most defaults are.

More in DNS & Domain Setup