21 March 2026 · 12 min read
SPF has a feature most domain owners never touch, never see in a generated record, and yet quietly relies on every time a large mailbox provider checks their mail: macros. They are the part of RFC 7208 that turns SPF from a static list of allowed IP addresses into a small, evaluated language. A macro lets a single published exists: term answer a different question for every sender, every recipient and every connecting IP, without the record itself changing. That is powerful, and it is also where some of SPF's sharpest edges live: extra DNS lookups, information leakage to anyone who probes your domain, and behaviour that depends on data an attacker partly controls.
This article is specifically about SPF macros: what the %{...} syntax actually means, which letters are available, the handful of real-world patterns that genuinely use them, and the security and lookup-count consequences you take on the moment you publish one. If you have never knowingly written a macro, you are still affected by them, because the big providers' include: records use them on your behalf. Knowing how they expand is the difference between trusting your SPF record and merely hoping it works.
What an SPF macro actually is
An SPF record is evaluated, not just read. When a receiving mail server gets a message, it looks up your v=spf1 record and works through the mechanisms left to right, asking for each one: does this connection match? Most mechanisms are literal. ip4:203.0.113.10 either matches the connecting IP or it does not. include:_spf.google.com pulls in another record and evaluates that. None of these change based on who the message is from or who it is to.
A macro changes that. It is a placeholder, written as %{ followed by a single letter and a closing }, that the receiver substitutes with a real value at evaluation time, drawn from the current SMTP transaction. So a term like:
exists:%{i}._spf.example.com
is not a fixed domain. Before the receiver issues the DNS query, it replaces %{i} with the IP address of the machine currently connecting, dotted out. If 198.51.100.7 is connecting, the receiver actually looks up:
198.51.100.7._spf.example.com
If that name resolves to anything (an A record), the exists mechanism matches and SPF passes. If it returns NXDOMAIN, it does not match and evaluation continues. You have, in effect, moved the decision out of the static SPF record and into your DNS zone, where you can answer it per IP, per sender, per recipient.
That is the whole idea. Macros let one published term ask a question whose answer is computed from the live transaction.
The macro letters, and where the data comes from
RFC 7208 defines a fixed set of single-letter macros. Each maps to a specific piece of the SMTP conversation or the SPF check itself. The ones you will actually meet:
%{s}: theMAIL FROMsender, the wholelocal-part@domain(the SMTP envelope sender, not the visibleFrom:header).%{l}: just the local-part ofMAIL FROM(everything before the@).%{o}: just the domain ofMAIL FROM(everything after the@).%{d}: the domain being used for this SPF check, which is normally the same as%{o}but can differ acrossinclude/redirectboundaries.%{i}: the IP address of the connecting client, expanded as dotted-decimal for IPv4 and nibble form for IPv6.%{p}: the validated reverse-DNS name of the connecting IP. Avoid this one; more below.%{v}: the literal stringin-addrfor IPv4 orip6for IPv6.%{h}: the HELO/EHLO hostname the client announced.
There are also %{c}, %{r} and %{t} (the connecting IP in human-readable form, the receiving domain, and a timestamp), but RFC 7208 restricts those to explanation strings only, the text used by the exp= modifier when a message is rejected. They cannot appear in mechanisms that affect the pass/fail result.
A worked expansion makes the data sources concrete. Suppose bob@strong-email.example.com sends from 192.0.2.3, and your record contains:
exists:%{i}.%{d}.spf.example.net
The receiver substitutes %{i} with 192.0.2.3 and %{d} with strong-email.example.com, then queries:
192.0.2.3.strong-email.example.com.spf.example.net
Every part of that name came from the live transaction. Change the sending IP or the sending domain and you get a different query, which is exactly the point.
Macro modifiers: reversing, trimming and delimiters
Plain substitution is rarely enough, because the values arrive in the wrong shape for DNS. DNS names read most-significant-on-the-right (www.example.com is a label www under example under com), but an IP like 192.0.2.3 reads most-significant-on-the-left. SPF macros therefore support transformations applied inside the braces, in the order: digits, then an r for reverse, then a delimiter set.
The pieces:
- A digit keeps only that many right-hand parts.
%{d2}onmail.corp.example.comyieldsexample.com(the two rightmost labels). - The letter
rreverses the parts.%{ir}on192.0.2.3yields3.2.0.192. - A delimiter list changes what counts as a separator before splitting. By default the separator is
., but%{l-}splits the local-part on-instead, and you can list several, for example%{o.-}.
The canonical pattern, the one you will see in real records, is the reverse-IP exists lookup used to build a per-IP allow database:
exists:%{ir}.%{v}.%{d}._spf.example.com
For IPv4 192.0.2.3 checking domain example.com, this expands to:
3.2.0.192.in-addr.example.com._spf.example.com
%{ir} reversed the octets, %{v} filled in in-addr, and the receiver now asks your DNS whether that specific reversed-IP name exists. You publish an A record for each authorised IP under _spf.example.com and SPF passes only for those. This is how some large senders run SPF for tens of thousands of customer IPs without ever exceeding the syntactic limits of a TXT record: the IP list lives in DNS, not in the SPF string.
URL-escaping, case, and the gotchas of substitution
Two details bite people who hand-write macros.
First, case. An uppercase macro letter applies URL-escaping (percent-encoding) to the result, intended for the rare case where a value goes into a URL, typically inside an exp= explanation. %{s} gives the raw sender; %{S} gives a percent-encoded version. For ordinary exists: mechanisms you almost always want the lowercase form. Mixing them up produces names that do not resolve and an SPF term that silently never matches.
Second, literal percent signs. Because % introduces a macro, you cannot write a bare %. The escapes are %% for a literal percent, %_ for a single space, and %- for a URL-encoded space (%20). You will rarely need these, but if a macro term is "almost working" and contains a stray %, this is the cause.
A subtle trap with %{s}, %{l} and %{o}: these come from the envelope MAIL FROM, which an attacker connecting to your receiver controls completely. Anything you build a DNS query from using those macros is a query whose name an outsider can influence. That is not automatically dangerous, but it is the seed of the security problems below, and it is why %{i} (the connecting IP, which the attacker cannot forge at the TCP level) is the safest macro to base decisions on.
Real use cases that justify the complexity
Macros are not academic. A few concrete patterns earn their keep.
1. Per-IP authorisation at scale. The reverse-IP exists pattern shown above lets an ESP or a large enterprise authorise a moving fleet of sending IPs without editing the published SPF string and without burning a separate ip4: entry per address. The SPF record stays tiny; the truth lives in a programmatically managed DNS subtree. When an IP is decommissioned, you delete one A record rather than re-publishing SPF.
2. Per-customer or per-subdomain delegation. A platform that sends on behalf of many customer domains can use %{o} or %{d} to route the check into a customer-specific branch of DNS:
exists:%{o}._customers.spf.platform.example
Now acme.com's mail is validated against acme.com._customers.spf.platform.example and globex.com's against its own name, all from one published term in the platform's include. This is one reason your provider's include: may contain a macro you never wrote.
3. Conditional logic SPF otherwise lacks. SPF has no "if". Macros plus exists: are the only way to make the result depend on transaction data, for example passing only when both a specific IP and a specific sending domain line up, by encoding both into the queried name. Without macros, SPF can only ever say "is the IP in this fixed set".
If your needs are simpler than any of these, you almost certainly should not be writing macros at all. A plain list of ip4: and include: mechanisms is easier to read, easier to audit, and cannot leak data. Our SPF generator produces exactly that kind of clean, literal record, and the guide to SPF records covers the non-macro mechanisms in order.
The DNS lookup cost: macros spend your budget too
SPF enforces a hard ceiling of ten DNS-querying mechanisms per evaluation, and crossing it returns permerror, which under DMARC counts as an SPF failure. People assume macros are exempt because they look like clever string substitution rather than a lookup. They are not exempt. An exists: mechanism costs one DNS query whether or not it contains a macro, and that query counts against the ten just like an include or an mx.
So a record built on macros is still bounded by the same budget covered in fix SPF too many DNS lookups and the 10-lookup limit glossary entry. The difference is in the shape of the cost. A clever macro design can replace many include: lines (each potentially expanding into several nested lookups) with a single exists: term that costs exactly one lookup, regardless of how many IPs sit behind it in DNS. Used that way, macros are a way to stay under the cap, not a way to escape it.
But the opposite happens too. There is a second limit that macros make easier to trip: the void-lookup limit. RFC 7208 says a receiver SHOULD return permerror after more than two DNS queries that come back with no answer (NXDOMAIN or empty NODATA). The reverse-IP exists pattern is built on lookups that deliberately return nothing for unauthorised IPs. If your design produces a no-answer result for a connection that then also hits one or two other void lookups elsewhere in the record, you can cross the void ceiling and PermError on a record that is otherwise correct. The void lookup glossary entry explains the counting. The practical rule: an exists macro term that returns NXDOMAIN on the common path is a liability, so design so that the expected outcome resolves.
A worse offender is %{p}. To expand %{p}, the receiver must perform a reverse DNS (PTR) lookup on the connecting IP and then validate it with a forward lookup. That is multiple queries, it is slow, it is easily spoofable by the connecting party's own reverse DNS, and RFC 7208 explicitly advises against using the p macro. If you see %{p} in a record, treat it as a defect.
The security implications, stated plainly
This is the part that matters most, because a macro can turn your DNS server into an oracle for an attacker.
Information disclosure. When you build a DNS query from %{s}, %{l} or %{o}, the local-part and domain of the envelope sender are placed into a DNS name your authoritative servers receive and log. Anyone who can make your receivers evaluate SPF, or who runs an authoritative-server path you do not control, can observe which senders are being checked. Worse, the attacker chooses MAIL FROM. They can connect to a server that evaluates your record and supply crafted local-parts, watching which queries your design issues. A macro that branches on sender data can be probed to map out the structure of your _spf subtree.
Attacker-controlled query construction. Because MAIL FROM is fully attacker-controlled, a macro like exists:%{l}.users.spf.example.com lets an outsider steer the queried name by setting the local-part. At minimum that is a way to generate arbitrary lookups against your DNS (a low-grade amplification or probing vector). If anything downstream of your DNS treats those names as trusted input, the blast radius grows. The mitigation is to base decisions on %{i}, which the attacker cannot forge below the TCP handshake, and to avoid %{s}/%{l}/%{o} in the pass/fail path unless you have thought through who can drive them.
The %{p} spoofing problem again. Beyond its lookup cost, %{p} derives from the connecting IP's reverse DNS, which is controlled by whoever controls that IP's PTR records, not by you. Authorising mail on the basis of %{p} means trusting a value your adversary may own. Do not.
Failure-mode opacity. A literal ip4: record is auditable by eye. A macro record is auditable only by simulating expansions for representative transactions, which most teams never do. That opacity is itself a risk: a misdesigned macro can fail open (matching when it should not) or fail closed (PermError under DMARC enforcement) in ways no static read of the record reveals. If you run macros, you need to verify the expanded result with a DMARC checker and a SPF checker against real sending IPs, and watch your aggregate reports, not just trust the published string.
How macros show up in your reports and checks
You will rarely write a macro, but you will encounter the consequences in two places.
In your DMARC aggregate (RUA) data, a macro-based SPF design that is misconfigured shows up as SPF permerror or unexpected fail rows for legitimate sources. Because the cause lives in DNS expansion rather than the visible record, it is easy to misdiagnose. The DMARC report analyzer groups results by sending source so you can spot a provider whose macro-driven include: has started failing for you. Reading those reports well is covered in aggregate report analysis.
In a checker, the record will look valid syntactically even when its runtime behaviour is broken, because static validation cannot evaluate a macro without a live transaction. This is the core reason "the record passes the linter but mail still fails" happens with macro-based senders. The fix is always to test expansion against a real or representative IP and sender, never to trust the static parse.
Should you use SPF macros at all?
For the overwhelming majority of domains, the honest answer is no. If you send from a fixed set of providers, the right SPF record is a short, literal list of include: and ip4:/ip6: mechanisms ending in -all or ~all, with the lookup count kept under control by consolidation or flattening rather than cleverness. That is readable, auditable and safe, and it is what our hosted SPF management publishes and maintains for you, including keeping you under the ten-lookup ceiling automatically as your senders change. The mechanics of staying under the cap without macros are in how SPF flattening works and the SPF flattening glossary entry.
Macros are the right tool in a narrow band: you are an ESP or a large enterprise authorising a large, changing fleet of IPs, you understand the void-lookup and information-disclosure trade-offs, you have based your decision logic on %{i} rather than attacker-controlled sender fields, and you have a way to test the expanded behaviour continuously. If that is not you, the existence of macros mainly matters because your providers use them, and you should be able to read %{ir}.%{v}.%{d}._spf... in their records and know exactly what it does and what it costs.
The practical takeaway
SPF macros turn the %{letter} placeholders in a record into live values from the SMTP transaction, most usefully %{i} for the connecting IP and the reverse/trim modifiers (%{ir}, %{d2}) that reshape those values into DNS names. They enable per-IP and per-customer authorisation at scale through a single exists: term, but they are not free: every exists still costs a DNS lookup against the ten-query limit, they make the void-lookup ceiling easier to hit, and any macro built from MAIL FROM data hands an attacker partial control over the queries your DNS receives. The %{p} macro is both slow and spoofable; avoid it entirely. If you do not have a genuine scale reason, keep SPF literal and let the macros stay where they belong, inside your providers' records.
If you want to see what your current record really evaluates to, run it through the SPF checker and confirm the DMARC result with the DMARC checker. And if keeping SPF correct, flat and under the lookup limit as your senders change sounds like work you would rather not own, that is exactly what our done-for-you DMARC and SPF service handles, no macros required on your side.