Bali, Indonesia
Insights

Why SPF Suddenly Returns permerror After Adding One More include

October 24, 2026
0:00
A domain's mail has passed SPF for months. Then someone adds a new newsletter tool, helpdesk, or CRM to the SPF record. Within a day, mail from every service starts landing in spam, and the receiving server's headers show spf=permerror instead of spf=pass. None of the old services changed, which makes this confusing. The edit that broke everything looks small and harmless. This article treats the problem as a post-mortem. It covers the impact, the order an investigation usually takes, where the real cause turns up, what to do in the first hour, and what to change so it does not happen again.
The key word is permerror, short for permanent error. It does not mean "this IP is not authorized." It means the receiver could not evaluate your SPF policy at all. The whole record is unusable, so it fails for every sender on the domain, including your main mail server, not only the service you just added. That is why the blast radius feels out of proportion to the change. What happens next depends on the receiver. Some treat permerror like a hard fail. Others treat it as neutral and let spam filtering decide. Under DMARC, permerror is not a pass. A message then passes DMARC only if it carries a DKIM signature that passes and aligns with the From domain. This explains a pattern support teams hear often: "only some of our mail goes to spam." The services with aligned DKIM keep delivering. The ones relying on SPF alone get filtered or rejected, especially if the DMARC policy is p=reject. The receiving server's Authentication-Results header is the fastest confirmation:
TEXT
# Illustrative output format (domains are placeholders)
Authentication-Results: mx.receiver.example;
  spf=permerror (too many DNS lookups) smtp.mailfrom=example.com;
  dkim=pass header.d=example.com;
  dmarc=pass header.from=example.com
The wording in the parentheses varies by receiver, and some give no reason at all.
The first checks tend to target the server. Is the sending IP on a blocklist? Does the PTR record match? Did something change in Exim or Postfix? None of these produce permerror, but they are reasonable to rule out because the symptom (mail in spam) looks the same. The next step is the SPF record. It exists, it contains the new include, and it looks syntactically fine to the eye. Many investigations stall here, because the record you see in the DNS editor is only the top layer. A receiver does not read just that line. It follows every reference in it, recursively, and counts as it goes. One detail worth getting right before going further: SPF is checked against the envelope sender, also called the Return-Path or MAIL FROM domain, not the From address the recipient sees. If a third-party service sends with its own bounce domain, your root SPF record may not be evaluated for that mail at all. This matters a lot for the fix later.
RFC 7208, the SPF specification, caps evaluation at 10 terms that trigger DNS queries. The terms that count are include, a, mx, ptr, exists, and the redirect modifier. The terms ip4, ip6, and all cost nothing because the receiver does not need DNS to check them. Going over 10 is a permerror, not a soft warning. Nested includes are where people lose count. An include points to another provider's SPF record, and that record often includes several more of its own. Each of those counts toward your 10. Here is how a record that looks like it has five or six terms can actually use eleven:
TEXT
# Illustrative example: lookup count for one domain (placeholder names)
v=spf1 a mx include:_spf.service-a.example include:spf.service-b.example include:mail.crm.example include:spf.newtool.example -all

a                              1
mx                             2
include:_spf.service-a.example 3
  -> includes 2 more records   4, 5
include:spf.service-b.example  6
  -> includes 2 more records   7, 8
include:mail.crm.example       9
  -> includes 1 more record    10
include:spf.newtool.example    11  -> permerror
The new tool was not the problem. It was simply the term that crossed the line. Removing any one of the earlier lookups would have worked just as well, which is useful to know when you decide what to cut. Two other causes produce the same error and are worth ruling out at the same time. The first is duplicate records. A domain must publish exactly one TXT record that starts with v=spf1. Two such records is a permerror, even if each is valid on its own. This often happens when a control panel creates a default SPF record and someone adds a second one for the new service instead of editing the first. The second is void lookups. A void lookup is a query that returns no answer or NXDOMAIN. The RFC recommends allowing no more than 2 of these. An include left behind for a cancelled service whose record was deleted can trigger this. You can check all three with dig, which is read-only:
BASH
# List every TXT record at the root.
# More than one line starting with v=spf1 is a permerror by itself.
dig +short TXT example.com

# Follow each include one level down, then repeat for anything it includes.
# Count every include, a, mx, ptr, exists, and redirect along the way.
dig +short TXT _spf.service-a.example
If a dig on an include returns nothing, it is a void lookup and probably a leftover worth removing.
The goal for the first hour is a single valid record with 10 or fewer lookups. Work in this order:
  1. If there are two v=spf1 records, merge them into one and delete the other. This alone fixes some cases.
  2. Remove includes for services the domain no longer uses. Old marketing tools and abandoned helpdesks are common leftovers.
  3. Replace a and mx with ip4: or ip6: entries when they resolve to your own server's fixed addresses. Each replacement saves a lookup. Only do this for addresses you control and know will not change.
  4. Remove ptr entirely. The RFC discourages it, and it costs lookups.
  5. If you are still over, take the new include out for now. Check whether that service signs with aligned DKIM, because if it does, its mail can pass DMARC without being in SPF while you plan the real fix.
After each change, recount the lookups. Remember that receivers may cache the old record until its TTL expires, so recovery is not instant even after the record is correct.
The first option most people find is flattening. You resolve every include down to its ip4 and ip6 ranges and publish those directly, for example v=spf1 ip4:192.0.2.10 ip4:198.51.100.0/24 ip6:2001:db8::/32 -all. That uses zero lookups. The trade-off is that you now own data that belongs to your providers. When a provider adds or changes sending ranges, your flattened record goes stale without any warning. Mail from the new ranges then gets a plain SPF fail rather than permerror, which is harder to spot. If you flatten, treat it as something that needs automation and monitoring, not a one-time edit. Also watch the record length: a single TXT string tops out at 255 characters, and long records must be split into multiple strings. The more durable fix is to give each third-party sender its own envelope sender domain, such as bounce.example.com or mail.example.com. Many email services support a custom return-path or bounce domain for this purpose. Check each service's documentation for the exact records it needs. That subdomain gets its own SPF record with its own budget of 10 lookups, and the root record only lists what actually sends with the root domain as the envelope sender. DMARC alignment is relaxed by default, so a subdomain of your organizational domain still aligns, unless your DMARC record sets aspf=s. Pair this with aligned DKIM on every service. SPF breaks for reasons that have nothing to do with you, such as forwarding, provider IP changes, or someone else's nested include growing. DKIM survives most of those, so DMARC has a second path to pass. In practice, the habit that prevents a repeat is simple. Before adding any include, count the lookups the record already uses and ask whether the new service even needs to be in the root record. If the count is already at eight or nine, the next edit should be moving a sender to a subdomain, not adding one more include.