SPF Beyond the Basics: include, redirect, exists, and Macros
Listing your IPs and slapping ~all on the end stops working the moment you hit the 10 DNS-lookup limit or need per-recipient policy. Here are the four mechanisms that turn a beginner SPF record into an engineered one — exact syntax, the trap in each, and a lookup-budget mental model.
EvilMail TeamJuly 18, 202611 min read
Most people learn SPF once: list your sending IPs, add ~all, publish, move on. That record works right up until the day it doesn't — when a fifth SaaS vendor pushes you past the 10 DNS-lookup ceiling, when marketing and billing need different sending policies, or when you have to authorize thousands of customer IPs that rotate weekly. At that point include and ~all run out of road, and adding more ip4: lines just makes the record unmaintainable.
Four mechanisms separate a beginner SPF record from an engineered one: include, redirect, exists, and macros. What follows is the exact syntax for each, the specific trap hiding inside it, and the thread that ties all four together — every one of them draws from the same tight lookup budget. If you already know what SPF is and now have to make it scale without tripping a permerror, this is the layer you were missing.
The 10-lookup wall is why these mechanisms exist
SPF include vs redirect, exists & Macros — Exact Syntax and Traps — EvilMail Blog
RFC 7208 §4.6.4 caps SPF evaluation at 10 DNS-querying mechanisms. The ones that count against the budget are include, a, mx, ptr, and exists, plus the redirect modifier. Cross that line and the receiver returns permerror — which many mailbox providers treat as a hard authentication failure, taking your DMARC alignment down with it.
What does *not* count matters just as much: ip4, ip6, all, and exp cost zero lookups. You can list five hundred ip4: CIDR blocks and spend nothing. That asymmetry is the whole game. Every mechanism here is a way of spending — or conserving — those ten lookups, and the gap between an amateur record and an engineered one is knowing which lever costs what.
There is a second, quieter limit people forget: the void-lookup cap. No more than 2 of your lookups may return NXDOMAIN or an empty answer before evaluation aborts with permerror. A dangling include pointing at a decommissioned vendor is a common way to blow this without ever approaching the count of ten.
include: delegate the decision, keep your policy
Syntax is a mechanism with a colon:
text
v=spf1 include:_spf.google.com ~all
include runs the target's SPF record as a sub-evaluation against the same client IP. Here is the part almost everyone gets wrong. A Pass inside the included record yields Pass for *your* domain and stops. But a Fail, softfail, or neutral inside the include does not fail your record — the sub-check simply returns "no match" and evaluation continues to your next mechanism. The included record's own -all is invisible to you; it governs only the match/no-match decision, never your terminal disposition.
So an include can never be the thing that *ends* your record on a failure. That job belongs exclusively to your own all. If you are counting on a vendor's -all to reject spoofers, you have misread the spec — it does not reach out of the sub-check.
On budget: each include costs 1 lookup minimum, plus every lookup the target performs, transitively. include:_spf.google.com looks like one line, but Google's record nests three more includes (_netblocks, _netblocks2, _netblocks3), so it spends 4 of your 10 before you add anything else. Stack a marketing platform and a helpdesk on top and you can hit 9 without noticing.
redirect: hand over the entire policy
redirect is a modifier, not a mechanism — note the =, not a colon:
text
v=spf1 redirect=_spf.provider.example
Where include borrows the target's match decision, redirectreplaces your record's result entirely, including the final all. Whatever ~all or -all the target publishes becomes *your* policy. You are not delegating a lookup; you are delegating the verdict.
Two rules govern when it fires. First, redirect is consulted only if no mechanism in your record matched. Second, it is ignored the moment an explicit `all` is present. That second rule is the classic footgun:
Here the -all matches everything that fell through, sets the terminal result, and the redirect is dead code — it never runs. Never combine redirect= with an all mechanism; pick one to own the ending. A correct redirect record carries no all at all:
text
v=spf1 redirect=_spf.corp.example
Cost is 1 lookup plus the target's lookups — the same accounting as include. The canonical use case is many domains sharing one centrally-managed policy: your fifty parked and brand domains all publish redirect=_spf.corp.example, and you maintain the real sender list in exactly one place. Update the target, every domain moves in lockstep.
The one-line rule: use `include` when your domain keeps its own `all`; use `redirect` when another record should own the whole policy, ending included.
exists: the conditional mechanism
text
v=spf1 exists:sender-check.example.com -all
exists performs an A-record lookup on the given name. If any A record comes back, the mechanism matches with its qualifier — the RRDATA is irrelevant, 127.0.0.1 matches exactly as well as 1.2.3.4. If the name is NXDOMAIN, it does not match and evaluation continues. It is the only SPF mechanism whose match depends on a query result rather than on whether the client IP falls in a listed range.
One sharp detail: it is always an A query, even for IPv6 senders. AAAA is never consulted. Qualifiers apply as everywhere else — +exists (default), ?exists, ~exists, -exists.
A static exists:sender-check.example.com is nearly pointless — it either always matches or never does. The power arrives only when you build the queried name dynamically from properties of the connection. Which means macros.
Macros: building the query string at evaluation time
A macro is %{letter}, expanded at evaluation against the current message. The letters worth knowing:
%{s} — full sender, local@domain
%{l} — local-part of the sender
%{o} — sender domain
%{d} — current domain being evaluated
%{i} — client IP (dotted for IPv4, nibble-expanded for IPv6)
%{h} — HELO/EHLO string
%{v} — the literal in-addr for IPv4, ip6 for IPv6
%{p} — validated PTR name — avoid it (below)
%{c}, %{r}, %{t} — valid only inside exp explanation text, not in mechanisms
Transformers refine each macro. A digit keeps that many trailing labels: %{d2} on mail.corp.example.com gives example.com. An `r` reverses label order. Custom delimiters split on characters other than .. An uppercase letter URL-escapes the output — %{S} percent-encodes the @ and anything else unsafe for a DNS label.
The workhorse is %{ir}: take the client IP and reverse it. For 192.0.2.3, %{i} is 192.0.2.3 and %{ir} is 3.2.0.192 — reverse-DNS order, exactly what you want as a query label. Literal specials round it out: %% is a percent sign, %_ a space, %- the string %20.
Skip %{p} in production. Resolving a validated PTR is slow, trivially spoofable by anyone who controls reverse DNS for their own IP, and it burns lookups. RFC 7208 says publishers SHOULD NOT rely on it. Treat ptr and %{p} as deprecated and move on.
exists + macros: DNS-driven allowlists and per-user SPF
This is how large senders authorize huge, churning IP sets with a single lookup instead of dozens of ip4: entries.
Pattern 1 — the DNS allowlist. Publish:
text
v=spf1 exists:%{ir}.%{v}._spf.example.com -all
Then run a DNS zone under _spf.example.com that answers an A record for every authorized IP's reversed form and NXDOMAIN for everything else. SPF spends exactly one lookup; the authorization set lives entirely in DNS and can hold millions of entries. Add or drop an IP by editing one zone record — no SPF republish, no lookup-budget impact.
Trace it for 192.0.2.3 sending as [email protected]: %{ir} becomes 3.2.0.192, %{v} becomes in-addr, and the resolver asks for an A record at 3.2.0.192.in-addr._spf.example.com. Answer present, +exists matches, Pass. NXDOMAIN, no match, and the trailing -all rejects.
Pattern 2 — per-local-part policy. Let different mailboxes resolve to different authorization:
text
v=spf1 exists:%{l}.%{o}._spf.example.com -all
Now [email protected] queries marketing.shop.example.com._spf.example.com and billing@ queries its own name — you decide in DNS which senders each local-part may use, with no combinatorial explosion of records.
Auditing and staying under budget
Verify with dig before you trust any record:
bash
# Your published record
dig +short TXT example.com
# What an include actually pulls in (count the nested lookups)
dig +short TXT _spf.google.com
# Does the allowlist zone answer for a given reversed IP?
dig +short A 3.2.0.192.in-addr._spf.example.com
Count your lookups. Run the record through a checker (dmarcian, mxtoolbox) that reports the resolved lookup total. Anything at 8+ needs attention before your next vendor addition tips it over.
Prefer one `exists`-macro allowlist over many nested includes. One lookup versus four-per-vendor is the difference between headroom and permerror.
Flatten only when you must. Replacing include: with literal ip4:/ip6: saves lookups but breaks silently the day the provider rotates IPs — do it only with automation that re-flattens and monitors for drift.
Never `ptr`, never `%{p}`. Slow, spoofable, deprecated.
`include` vs `redirect` in one line. Keeping your own all → include; another record owns the whole policy → redirect, and then no all.
Watch void lookups. Two NXDOMAIN results and you permerror. Prune dead includes pointing at retired vendors.
Test the terminal case. Send from an unauthorized IP and confirm you get the fail you expect — a record that never rejects anything is decoration, not authentication.
The mental model to carry: ip4/ip6/all are free, everything else costs at least one lookup transitively, include borrows a decision while redirect hands over the verdict, and exists plus macros buys you an unbounded allowlist for the price of a single query.