Back to blog
Threat Intel
Phishing Forensics

SPF Macros: Building Dynamic Sender Policies Without Causing PermErrors

Go beyond static SPF includes and learn to use dynamic macros like %{i} and %{h} to authorize senders surgically, without breaking the 10-lookup limit.

MailSleuth Research
Email Security Team
July 8, 20268 min read
An editorial illustration showing mechanical gears labeled 'i', 'l', and 'h' assembling a DNS query, symbolizing dynamic

You get the alert: SPF failed for an email from your CFO's account. But the `Authentication-Results` header shows the email originated from a legitimate, trusted vendor. The second `Received` header gives it away—it was forwarded. The original sender's IP is lost, the forwarder's IP isn't in your SPF record, and the chain of trust breaks. This is a daily reality for SOC analysts, and it’s a problem that static SPF records were never designed to solve.

This is where SPF macros enter the picture. Defined back in RFC 4408 and refined in RFC 7208, macros are variables you can embed in your SPF record. When a receiving Mail Transfer Agent (MTA) evaluates your policy, it substitutes these variables with data from the actual SMTP session—the sender's IP address, the HELO domain, the email's local-part, and more. They allow you to build a policy that isn't just a static list of IPs and domains, but a dynamic, conditional logic tree.

But they are not a silver bullet. Misunderstanding their syntax, performance overhead, and testing limitations can lead to a state worse than a simple `softfail`—a permanent error (`PermError`) that effectively renders your SPF record useless. Let's build a working model of how to use them correctly.

Anatomy of an SPF Macro

At its core, a macro is a placeholder inside your SPF string that starts with `%{` and ends with `}`. The receiving server is responsible for expanding it before performing a DNS query. It's a one-way transformation using data from the live SMTP session.

The Core Variables

RFC 7208 defines a handful of these variables. While you can get granular, most practical applications revolve around just a few:

`%{i}` is the IP address of the SMTP client. This is the star of the show for most dynamic use cases. It allows you to create rules based on the actual machine that connected to the receiving MTA. For IPv4, it's a standard dot-decimal address; for IPv6, it's the standard colon-separated hex representation.

`%{h}` is the HELO/EHLO domain. This is the identity the sending server presents at the start of the SMTP conversation. It can be useful for authorizing specific mail fleets that use a consistent HELO naming scheme, even if their IPs change.

`%{s}` is the sender's full email address (e.g., `user@example.com`). `%{l}` is the local-part (`user`), and `%{d}` is the domain (`example.com`). While `%{d}` is already the domain being checked, `%{l}` can facilitate incredibly granular, per-user policies. This path is tempting but often leads to an unmanageable DNS mess.

Transformers and Delimiters

You can also add transformers to manipulate the expanded string. You can reverse the parts (`r`), specify a delimiter (like `.` or `-`), or truncate parts of the string. The most common use is reversing an IP address for a DNS query. For example, `%{i.r}` would turn `192.0.2.1` into `1.2.0.192`. This is often done to match conventions used in DNS blocklists.

Use Case: Taming the Forwarding Problem

Let's return to our forwarded email scenario. A vendor receives an email from your CFO and automatically forwards it to a project manager at your company. The connection an MX server sees comes from the vendor's forwarder, not your corporate mail gateway. Your static `include:sendgrid.net` or `ip4:1.2.3.4` is useless here.

A standard, but overly permissive, fix is to add the forwarder's entire mail infrastructure to your SPF record. This is a terrible idea. You're now telling the world that this third-party vendor is a valid sender for your domain, opening a potential avenue for impersonation.

Macros offer a scalpel. Imagine your authorized forwarders all come from IPs owned by `forwarding-partner.com`. You can create a specific subdomain in your DNS, say `_spf.yourdomain.com`, and populate it with `A` records for the specific IPs of their forwarding servers. Then, your SPF record can use the `exists` mechanism with the `%{i}` macro.

v=spf1 include:_spf.google.com exists:%{i}._spf.forwarding-partner.com -all

When an email arrives from a forwarder at IP `198.51.100.50`, the receiving MTA doesn't just check `forwarding-partner.com`. It constructs a specific DNS query: `198.51.100.50._spf.forwarding-partner.com`. If you have created a corresponding `A` record for that FQDN, the DNS query will succeed, the `exists` mechanism returns `pass`, and the SPF check passes. If the IP is not one you've explicitly allowed, the query returns `NXDOMAIN`, `exists` fails, and the check moves to the next mechanism (`-all`), resulting in a `fail`. You've authorized a specific infrastructure path without blanket-approving an entire service.

The Hidden Costs: DNS Lookups and Performance Drag

This surgical power comes at a cost. SPF has a hard limit, specified in RFC 7208 section 4.6.4, of 10 mechanisms that require DNS lookups. Violating this limit results in a `PermError`, making your entire policy invalid. It's a protection against denial-of-service attacks via DNS amplification.

Amplifying the Lookup Limit

A single `exists:%{i}._spf.example.com` mechanism is just one DNS lookup against your 10-mechanism budget. The danger isn't the macro itself, but how it interacts with other mechanisms, especially nested `include` statements. A common mistake is to place a macro-based `include` within a chain of other `include`s. If `yourdomain.com` includes `_spf1.yourdomain.com`, which in turn includes `_spf.service1.com` and `_spf.service2.com`, you're already at 3 lookups. Add a few more services, and a single dynamic macro, and you can easily breach the limit.

Worse, this can be invisible during testing. If your test suite doesn't fully expand every `include` and `redirect`, it won't see the full chain and won't flag the `PermError`. Production MTAs, however, will.

The Per-Message Performance Tax

Every email that triggers a macro-based check adds a tiny bit of latency. The receiving MTA must construct the string, query DNS, and wait for a response. For one email, it's negligible. For an MTA processing thousands of connections per second, this adds up. If your DNS hosting is slow, or if the receiving MTA has a slow or overloaded resolver, these checks can time out. This often results in a `TempError` (temporary error), which most receivers treat as a neutral result. It's not a `fail`, but it's not a `pass` either, depriving you of the DMARC alignment you were trying to achieve.

Why Generic SPF Checkers Fail to Validate Macro Records

If you've ever pasted a macro-heavy SPF record into a free online checker, you've probably seen it spit back an error or simply ignore the dynamic parts. This isn't because the tool is broken; it's because it lacks context.

A simple web-based validator only has one piece of information: your domain name. It can fetch your TXT record and recursively resolve static `include` mechanisms. But it has no idea what `%{i}` or `%{h}` should be. There is no live SMTP session. It can't know the connecting IP address or the HELO string from a hypothetical email.

To validate a macro, you can't just analyze the policy. You have to simulate the transaction. — Anonymous MTA Admin

Proper validation requires a tool that allows you to specify the inputs for the macros. You need to be able to say, "Check this SPF record as if it were receiving an email from IP `203.0.113.10` with a HELO of `mta-1.mailer.com`." Without this ability, you are flying blind. You can manually perform `dig` or `nslookup` queries for a few test cases (e.g., `nslookup 10.113.0.203.your-spf-zone.com`), but this doesn't scale and can't easily spot issues like the 10-lookup limit when macros are involved in complex chains.

This is a critical gap for security teams. You push a change to your SPF record to authorize a new forwarding service, your generic tools say it's fine, and a week later you find out half your partner emails are being rejected because of a `PermError` that only manifests under specific transactional conditions.

`exists` vs. Macro-Driven `include`: Choosing Your Tool

The two primary ways to implement dynamic SPF are via the `exists` mechanism or by placing macros inside an `include`. They solve similar problems but have different operational implications.

As we've seen, `exists:%{i}.sub.domain.com` is a precise tool for authorizing IPs. You manage a block of `A` records in a dedicated DNS zone. It's clean, targeted, and relatively easy to reason about. Each check is one lookup. This is the preferred method for managing authorized forwarders or dynamic fleets.

The alternative is a macro-driven `include`, such as `include:%{l}._spf.yourdomain.com`. On its face, this is incredibly powerful. You could create a unique SPF policy for every single user on your domain by creating a TXT record at `ceo._spf.yourdomain.com`, `sales-team._spf.yourdomain.com`, etc. This would allow the CEO to send from a home IP or a special service not authorized for anyone else.

In reality, this is an operational nightmare. The proliferation of DNS records is immense, and managing them becomes a full-time job. More importantly, each `include` resolves to another SPF record, which can have its own `include`s, all of which count against the same 10-lookup limit. It's far too easy to create a situation where an email from `user-a` passes but an email from `user-b` fails with a `PermError` because their specific policy has one too many lookups. For this reason, `exists` is almost always the more stable and scalable choice for dynamic authorization.

The takeaway

SPF macros are a specialist's tool. They provide the surgical precision that static policies lack, especially when dealing with the messy reality of email forwarding and complex sending environments. The `exists` mechanism, powered by the `%{i}` macro, is the most reliable way to implement them, allowing you to authorize specific IPs without dangerously broadening your policy.

But this power demands discipline. You must be vigilant about the 10-lookup limit and understand that your standard validation tools are likely insufficient. Every macro you add is another point of potential failure that requires contextual, scenario-based testing. When triaging deliverability incidents involving these complex policies, inspecting the raw headers to see how a specific MTA evaluated the macro is paramount, which is where a dedicated analysis platform like MailSleuth.AI can turn hours of guesswork into a clear verdict.

#spf#dmarc#email-security#dynamic-spf-macros#dns
MailSleuth Research
Email Security Team

We dissect phishing campaigns and email infrastructure so you don't have to.