Skip to content

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

18 comments

11 comments

  1. Aditi
    thanks for the explicit cautionary tales. the alignment-vs-pass distinction is exactly where i lost a week last year.
    1. Admin
      Everyone loses that week. SPF checks Return-Path, DMARC checks it against From:, so your ESP's bounce domain passes green and fails alignment anyway. Custom Return-Path or DKIM alignment.
  2. Marcus
    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...
  3. Mai
    hit the 10-lookup spf limit when we tried to layer ses on top of an existing google workspace setup. flattened with a tool (spfwizard.com) and its been fine since. that tool's worth a mention.
  4. Yuki
    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
      There's no bulk verify button today. Verification is per sending domain, you add the records on the customer's zone and hit check on that domain. For white-label at any real volume people script it: resolve the TXT for each domain yourself and only push the ones that already answer correctly into the app. A dig loop over your domain list gets you 90% of the way, the SPF part is just checking the published record includes your include: mechanism and that the whole chain still resolves under the 10 lookup limit, which is the thing that actually bites when a customer already has Google plus a CRM plus your include stacked up. If the customer controls DNS themselves, delegating a subdomain to you (mail.theirdomain.com with an NS record pointing at your zone) is the cleaner setup. Then you own the records and never chase them again. That's how I'd do it for anything past a handful of clients. A real bulk-check endpoint is on the list, no date on it.
  5. Priya
    what's your recommendation for sub-domains? we send from mail.example.com and notifications.example.com. same dkim selector or separate? tbh
    1. Admin
      Separate selectors. DKIM lookups are scoped to the signing domain, so a key published at `s1._domainkey.mail.example.com` does nothing for `notifications.example.com`. Nothing inherits from the parent domain. You'd have to publish the key twice anyway, and at that point separate selectors cost you nothing and let you rotate or revoke one stream without touching the other. Same story for SPF, which is the part people get bitten by. SPF has no inheritance either, so `mail.example.com` and `notifications.example.com` each need their own TXT record. A record on `example.com` is only consulted when the bounce address is actually `@example.com`. If you're sending transactional from one subdomain and marketing from the other, keeping them fully split is the right call, reputation stays isolated when one stream picks up complaints.
  6. Thuỳ
    DNS setup is one of those things where you don't know what you don't know. This article should be required reading for anyone running their own mail
    1. Admin
      The part people don't know they don't know is usually the 10 DNS lookup limit. Everything works fine until you add one more `include:` and the whole record silently goes permerror, and nothing in your mail client tells you. That's why the lookup counting section is the longest one in the article. If there's a specific edge case that bit you and it's not covered, tell me and I'll add it.
  7. Anna
    The SPF flattening explanation finally made it click for me. I'd been hitting the 10-lookup limit and didn't understand why nesting includes counted.
  8. Rafael
    easy win: set up dmarcian.com (free tier) to receive your DMARC RUA reports. The first 2 weeks of reports tell you everything you didn't know about who's sending as you
  9. Carlos
    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
      It varies by receiver, which is why you're hearing mixed things. Gmail and Outlook don't outright reject on -all in most cases, they feed it into a broader score, so an SPF hard fail alone usually lands you in spam rather than getting a 5xx at the door. Plenty of smaller providers and corporate gateways with strict filters do reject on it. The bigger reason to care about -all vs ~all is DMARC. If you're at p=reject, an SPF fail on a non-aligned domain gets treated the same either way, since DMARC looks at pass/fail, not the qualifier. So -all mostly matters for mail that isn't covered by DMARC alignment. Practical advice: stay on ~all until your DMARC aggregate reports come back clean for a few weeks and you can account for every sending source in them. Forwarding is what usually bites people, it breaks SPF and -all makes that failure louder. Once the reports show no legitimate senders failing, switching to -all is low risk.
  10. Ravi
    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
    1. Admin
      That one is nasty because the apex is exactly where the SPF TXT has to live, and the auto-DNS managers assume they own every record on it. Netlify's apex handling has bitten people the same way when you point the zone at their nameservers and let them manage records. Worth adding: this failure is silent. The record doesn't go malformed, it just disappears, so an SPF syntax checker tells you nothing and your mail keeps flowing on p=none until a receiver actually enforces. A `dig +short TXT yourdomain.com` on a schedule catches it in a way a validator won't. I'll put a note in the edge cases section about apex records under platform-managed DNS. If you remember whether it was on a redeploy or a domain re-verify that stripped it, that detail would make the warning more useful than a general "watch out".
  11. Chen
    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.
    1. Admin
      That order is right, and the failure mode you hit is the classic one. Publish the new selector, leave the old TXT record in place, wait out the TTL plus whatever slack you want for resolvers that ignore it, then flip the signing selector, then delete the old record last. Mail already in a queue or a retry loop can get delivered hours after it was signed, so the old selector has to stay resolvable well past the switch. 48h is a reasonable floor if your TTL is 3600 or less. This article is SPF only, so DKIM selector rotation isn't covered here. We should have a rotation runbook with the ordering spelled out. I'll write one up and link it from here.

More in DNS & Domain Setup