Skip to content

SPF flattening: when it helps and when it breaks mail

Flattening replaces includes with IP ranges to dodge the lookup limit. Here is how it works, why it rots, and safer alternatives like subdomains.

Koltrix Team5 min read
Yellow and green patch cables neatly connected to a panel
Photo by Albert Stoynov on Unsplash
On this page(8 sections)
  1. What flattening does
  2. Why it rots
  3. Record size becomes a problem
  4. When flattening is reasonable
  5. Cleaner alternatives to try first
  6. Remove dead weight
  7. Use subdomains for the envelope sender
  8. Rely on aligned DKIM where appropriate
  9. Drop mx and a if they are not senders
  10. A minimal automation sketch
  11. Signs your flattened record has gone stale
  12. Bottom line

SPF flattening is the duct tape of email authentication: it gets you under the ten-lookup limit this afternoon and fails quietly six months from now. Used carefully, it is a legitimate tool.

Used casually, it is a time bomb.

What flattening does

An SPF record with several include: mechanisms makes the receiver perform a DNS query for each include, plus queries for anything nested inside them. Flattening resolves all of that ahead of time and replaces the includes with the literal IP ranges they contain.

Before:

v=spf1 include:_spf.mailhost.example include:spf.crm.example include:mail.helpdesk.example ~all

After:

v=spf1 ip4:192.0.2.0/25 ip4:192.0.2.128/26 ip4:198.51.100.0/24 ip6:2001:db8:10::/48 ip4:203.0.113.32/27 ~all

Because ip4: and ip6: do not require DNS lookups, the flattened record costs zero lookups no matter how many ranges it lists. Problem solved, on paper.

Why it rots

The includes you removed were not decoration. They were a delegation: "whatever IPs this vendor says it uses today are authorized." When you flatten, you replace a live delegation with a snapshot.

Vendors change their sending infrastructure for many reasons: new data centers, migrations between cloud providers, new IP pools for warm-up, retiring old blocks. Large providers publish their ranges through SPF precisely so customers do not have to track these changes. When your flattened record misses a new range:

  • Mail from the new IPs fails SPF.
  • If the vendor also signs with an aligned DKIM key, DMARC may still pass, and nobody notices.
  • If it does not, or if a receiver weighs SPF separately, mail starts landing in spam or bouncing.
  • The failure is intermittent, because only some of the vendor's traffic moved.

That last point is what makes stale flattening so painful to debug. Most messages work. A fraction fail. The record looks valid in every checker.

There is also a quieter risk in the other direction. When a vendor retires a range, your snapshot still authorizes it. If that range is later reassigned to someone else, you have authorized a stranger to send as your domain.

Record size becomes a problem

Flattening also tends to produce long records. Some vendors publish dozens of ranges. Each TXT string is limited to 255 characters, so long records are split into multiple quoted strings, which is legal and concatenated by receivers. But very large DNS responses can exceed the classic 512-byte UDP limit and require EDNS or a retry over TCP. Most modern resolvers handle this, but not every network path does, and a TXT response that fails to arrive at all yields a temperror rather than a pass.

You can split a large flattened set across multiple records chained with include: to your own subdomains (_spf1.example.com, _spf2.example.com). That reintroduces some lookups, but you control them.

When flattening is reasonable

Flattening is defensible under specific conditions:

  1. It is automated. A job re-resolves the original includes on a schedule (hourly or daily), regenerates the record, and publishes it through your DNS provider's API.
  2. It is monitored. The job alerts when a vendor's resolved ranges change, when publishing fails, or when the regenerated record would exceed size limits.
  3. The source of truth is preserved. You keep the original include list in version control, so anyone can see what the flattened record is supposed to represent.
  4. You have exhausted cleaner options. Removing unused senders and moving senders to subdomains usually solve the problem without any of the risk.

Hosted flattening services exist that do the automation for you, typically by having you include: a record they maintain. That works, but you are now trusting a third party with your SPF authorization, and you are still spending one lookup on their include.

Cleaner alternatives to try first

Remove dead weight

Audit which includes still correspond to services that send for you. DMARC aggregate reports show which source IPs actually send with your domain. Anything with no traffic for a few months is a candidate for removal.

Use subdomains for the envelope sender

SPF checks the envelope sender domain, not the From header. If your marketing platform uses bounce.news.example.com as its Return-Path, the SPF check happens against that subdomain's record, with its own lookup budget. Relaxed DMARC alignment still lets a From address at example.com pass on that SPF result.

This is the single most effective fix for lookup pressure, and most providers support custom bounce domains.

Rely on aligned DKIM where appropriate

DMARC needs only one aligned pass. A vendor signing with a DKIM key under your domain (often via CNAMEs you publish at a selector) can pass DMARC without appearing in your root SPF record. This is not a reason to drop SPF everywhere, but it is a reason not to cram every vendor into one record.

Drop mx and a if they are not senders

These cost lookups and often authorize hosts that never send outbound mail.

A minimal automation sketch

If you do flatten, the job looks roughly like this:

import dns.resolver, ipaddress

SOURCES = ["_spf.mailhost.example", "spf.crm.example"]

def expand(name, depth=0, seen=None):
    seen = seen or set()
    if depth > 10 or name in seen:
        raise RuntimeError(f"loop or depth exceeded at {name}")
    seen.add(name)
    nets = set()
    for rr in dns.resolver.resolve(name, "TXT"):
        txt = b"".join(rr.strings).decode()
        if not txt.startswith("v=spf1"):
            continue
        for term in txt.split()[1:]:
            if term.startswith(("ip4:", "ip6:")):
                nets.add(str(ipaddress.ip_network(term[4:], strict=False)))
            elif term.startswith("include:"):
                nets |= expand(term[8:], depth + 1, seen)
            elif term.split(":")[0] in ("a", "mx", "exists", "ptr") or term.startswith("redirect="):
                raise RuntimeError(f"unsupported term {term} in {name}; review manually")
    return nets

The important part is not the code. It is that the job refuses to guess when it meets a mechanism it cannot safely flatten, and that a human gets an alert.

Signs your flattened record has gone stale

If you inherit a flattened record, look for these symptoms: DMARC reports showing a vendor's mail failing SPF from IPs you do not list, intermittent spam placement for one tool while others are fine, and ranges in your record that no longer appear in the vendor's published SPF. Comparing your record against a fresh resolution of the vendor's include usually confirms it within minutes.

Bottom line

Flattening trades a hard, visible failure (permerror from too many lookups) for a soft, invisible one (stale IP ranges). Try removing unused senders and moving providers onto their own bounce subdomains first. If you still need flattening, automate the refresh, alert on changes, keep the original include list in version control, and treat the flattened record as generated output, never as something a person edits by hand.

Start with Koltrix

Your domain, one inbox, and an API that sends.

A team inbox where AI sorts and drafts (nothing is sent without your click), plus the transactional API and SMTP relay your product sends with. 7 days free, no card.

SharePost on XLinkedIn