Science
SPF Record Syntax: Mechanisms, Qualifiers, Modifiers, and Macros
Key Points
An SPF record is one string of text in the RDATA of a single TXT record, and its grammar has exactly three kinds of parts: a version tag, mechanisms (each with an optional qualifier), and modifiers. The version tag must be exactly v=spf1 — a record starting v=spf10 is discarded, not partially matched (§4.5). The grammar in brief, from §3 and §4.6.1: | Part | Form | Role | |---|---|---| | Version | v=spf1 , always first, exact | Selects the record (§4.5) | | Mechanism |...
An SPF record is one string of text in the RDATA of a single TXT record, and its grammar has exactly three kinds of parts: a version tag, mechanisms (each with an optional qualifier), and modifiers. The version tag must be exactly v=spf1
— a record starting v=spf10
is discarded, not partially matched (§4.5).
The grammar in brief, from §3 and §4.6.1:
| Part | Form | Role |
|---|---|---|
| Version | v=spf1 , always first, exact | Selects the record (§4.5) |
| Mechanism | [qualifier]name[:arg][/cidr] | Tested against the client IP; can match or not match (§4.6.2) |
| Qualifier | + - ~ ? before mechanism | The result returned when its mechanism matches (§4.6.2) |
| Modifier | name=value , at most once each | Extra information; never matched (§6) |
Terms are separated by spaces. Mechanism names are case-insensitive, and terms containing none of =
, :
, or /
are mechanisms (§4.6.1). One syntax error anywhere invalidates the whole record: check_host() — the receiver’s evaluation routine, as the RFC names it — validates SPF syntax first and returns PermError immediately if anything is malformed, without evaluating a single term (§4.6). That is why one stray character can fail authentication for every message a domain sends.
Eight mechanisms exist, and each either matches the connecting IP or does not. RFC 7208 §5 splits them into basic framework mechanisms (all
, include
) and designated-sender mechanisms (a
, mx
, ptr
, ip4
, ip6
, exists
). Before the full table: most real-world records use only include
, ip4
, ip6
, and all
— the rest of this reference exists so you can read other people’s records, not because your own needs them.
| Mechanism | Syntax | Matches when… | Counts toward 10-lookup limit? | RFC § |
|---|---|---|---|---|
all | all | Always | No | §5.1 |
include | include:domain | The referenced record returns Pass | Yes | §5.2 |
a | a[:domain][/cidr] | Client IP is among the domain’s A/AAAA addresses | Yes | §5.3 |
mx | mx[:domain][/cidr] | Client IP is an address of one of the domain’s MX hosts | Yes (plus per-MX address caps) | §5.4 |
ptr | ptr[:domain] | Reverse DNS validates into the target domain | Yes | §5.5 |
ip4 | ip4:network[/cidr] | Client IP is inside the IPv4 network | No | §5.6 |
ip6 | ip6:network[/cidr] | Client IP is inside the IPv6 network | No | §5.6 |
exists | exists:domain | The constructed domain has any A record | Yes | §5.7 |
You can parse any published record term-by-term — with each mechanism’s cost and RFC cite attached — using the SPF syntax inspector.
all
all
always matches, which is why it belongs at the end as the explicit default (§5.1). Everything after it is dead text: “Mechanisms listed after all
MUST be ignored,” and any redirect=
modifier is ignored whenever all
appears anywhere in the record (§5.1). A record without a trailing all
or redirect=
silently defaults to Neutral (§4.7).
include
include:domain
recursively evaluates the referenced domain’s SPF record and matches only when that evaluation returns Pass (§5.2). It is not a splice. The RFC itself concedes the name was “poorly chosen”: a -all
inside an included record does not fail your outer record — a Fail, Softfail, or Neutral inside simply means “no match here, keep going” (§5.2). Better mental model: if-match
. One consequence worth knowing: if the included domain publishes no SPF record at all, include
returns PermError (§5.2).
a
and mx
a
matches when the client IP is one of the target domain’s A or AAAA addresses; mx
matches when it is an address of one of the domain’s MX hosts (§5.3, §5.4). Both default to the current domain when no argument is given, and both accept dual CIDR suffixes — a/24
compares only the top 24 bits, and a:example.com/24//64
sets IPv4 and IPv6 prefixes separately (§5.3). Note the cost asymmetry: mx
is one term against the 10-lookup limit, but evaluating it triggers one MX query plus an address query per MX host, capped at 10 address records before PermError (§4.6.4).
ip4
and ip6
ip4:
and ip6:
test whether the client IP falls inside a literal network, using a colon — ip4:192.0.2.0/24
, never ip4=
(§5.6). Omitted CIDR lengths default to /32
and /128
, i.e., exact-address match, and truncated addresses like 192.0.2
are not permitted (§5.6). These are the only designated-sender mechanisms with zero DNS cost, which makes them the cheapest terms in any record.
exists
exists:domain
constructs a domain name, queries it for an A record, and matches if any A record comes back — regardless of its value, and always an A query even on IPv6 connections (§5.7). One lookup, arbitrary logic. Combined with macros (§7), it powers dynamic authorization: publish per-IP hostnames in a zone you control, and the record authorizes exactly those IPs without ever listing them. This is the pattern behind Salesforce’s current record, shown in the examples below.
ptr
(do not use)RFC 7208’s own heading for §5.5 is literally "ptr" (do not use)
: the mechanism “SHOULD NOT be published” because it is slow, unreliable under DNS errors, and burdens the .arpa
name servers. Receivers must still support it, but you should never publish it.
Next step: paste your own record into the SPF syntax inspector and confirm every mechanism in it earns its DNS cost.
A qualifier is a single prefix character that sets the result returned when its mechanism matches — and when you omit it, the default is +
(§4.6.2). mx
and +mx
are the same term.
| Qualifier | Result | Meaning (§2.6) |
|---|---|---|
+ | Pass | The client is authorized (default when omitted) |
- | Fail | The client is explicitly not authorized |
~ | Softfail | Probably not authorized; the domain isn’t willing to state a hard Fail |
? | Neutral | The domain asserts nothing about the client |
In practice the choice comes down to the final term: -all
versus ~all
. Receivers treat them differently, and mail flows through forwarders complicate the picture — we cover the trade-off in depth in softfail vs hardfail.
If you also enforce DKIM and DMARC, publish -all
; if you are still discovering senders, start with ~all
and tighten once softfail vs hardfail settles the trade-off for your domain.
Modifiers are name=value
pairs — always an equals sign, never a colon — that provide information rather than being matched (§6). RFC 7208 defines two, each allowed at most once; a duplicated redirect=
or exp=
is a PermError, while unrecognized modifiers are ignored no matter how often they appear (§6).
redirect=
redirect=domain
hands the entire evaluation to another domain’s record — but only after every mechanism has failed to match (§6.1). The other record’s result becomes your result, with one sharpening: where a missing record would normally give None, a redirect=
to a domain with no SPF record gives PermError (§6.1). Two rules people miss: redirect=
counts as one DNS lookup (§4.6.4), and it “MUST be ignored” whenever an all
mechanism appears anywhere in the record (§6.1).
How it differs from include
:
| Behavior | include: (§5.2) | redirect= (§6.1) |
|---|---|---|
| Matches on | Pass only; otherwise evaluation continues | Not a mechanism; applies after all misses |
| Effect of referenced result | Only Pass propagates as a match | Result replaces yours entirely |
With all in the record | Unaffected | Ignored |
| Referenced record missing | PermError | PermError |
| Intended use | Crossing administrative boundaries | Sharing one policy across your own domains |
exp=
exp=domain
names a TXT record whose macro-expanded string is returned as the explanation when a message Fails (§6.2). It is the only term in the language that never counts toward the lookup limit — §4.6.4 exempts exp
explicitly, because its lookup happens after evaluation, and only on Fail.
Macros are %{...}
sequences expanded at evaluation time from properties of the message — and they are the least-covered corner of SPF record syntax despite being fully specified in RFC 7208 §7. The ABNF defines exactly eleven macro letters: s
, l
, o
, d
, i
, p
, h
, c
, r
, t
, v
(§7.1).
| Macro | Expands to (§7.2) | Notes |
|---|---|---|
%{s} | The full sender, e.g. [email protected] | |
%{l} | Local-part of the sender (user ) | Limits caching (§7.3) |
%{o} | Domain of the sender | |
%{d} | The domain being evaluated | |
%{i} | The connecting client IP | Dotted-quad IPv4 / dot-separated nibbles IPv6 (§7.3) |
%{p} | Validated reverse-DNS name of the IP | ”do not use” (§7.2, §5.5) |
%{v} | in-addr for IPv4, ip6 for IPv6 | |
%{h} | HELO/EHLO domain | |
%{c} | Client IP, human-readable | exp text only (§7.2) |
%{r} | Domain of the host performing the check | exp text only (§7.2) |
%{t} | Current timestamp (seconds since epoch) | exp text only (§7.2) |
Three literal escapes complete the set: %%
is a percent sign, %_
a space, %-
a URL-encoded space (§7.1).
Transformers modify the expansion inside the braces (§7.3). A digit keeps only that many right-hand parts: %{d2}
on email.example.com
gives example.com
. The letter r
reverses the parts on dots: with client IP 192.0.2.1, %{i}
is 192.0.2.1
and %{ir}
is 1.2.0.192
. Custom delimiters re-split on other characters — %{l-}
splits the local-part on hyphens — and parts are always rejoined with dots.
The RFC’s own worked example in §7.4 ties it together. With client IP 192.0.2.3 and domain email.example.com
:
%{ir}.%{v}._spf.%{d2}
→ 3.2.0.192.in-addr._spf.example.com
Pair that with exists:
and you get per-IP authorization in a single lookup — the §5.7 example v=spf1 exists:%{ir}.%{l1r+-}._spf.%{d} -all
makes decisions “at the level of the user and client IP address.” So what does %{i}
mean in an SPF record? It is the connecting client’s IP address — dotted-quad for IPv4, dot-separated nibble format for IPv6 — and it is the macro that makes dynamic gateway records work (§7.2). You can expand any macro against a live IP with the SPF macro debugger.
One caution from the RFC itself: records using s
, l
, o
, or h
macros defeat result caching, so §7.3 advises sending domains to avoid them with mechanism directives. Before publishing any macro, expand it against a real client IP in the SPF macro debugger and check the constructed name resolves the way you expect.
Evaluation is first-match-wins: mechanisms are considered left to right, the first one that matches ends processing, and its qualifier becomes the result (§4.6.2). Order is not stylistic — it is the semantics.
Trace client IP 198.51.100.7 through this record:
v=spf1 ip4:192.0.2.0/24 -a mx ~all
1. ip4:192.0.2.0/24 → 198.51.100.7 not in range → no match, continue
2. -a → IP not among the domain's A records → no match, continue
3. mx → IP matches an MX host's address → MATCH — stop here
Result: Pass (the matching mechanism's implicit "+")
The ~all
is never reached. Now invert the case: if the IP had matched step 2, the result would be Fail — and no later mechanism could rescue it. A -
qualifier early in the record is final for any IP it matches, no matter what an include
further right would have said. This also answers the ordering question directly: yes, mechanism order matters, because evaluation stops at the first match (§4.6.2).
If nothing matches and there is no redirect=
, the default result is Neutral, “just as if ?all
were specified as the last directive” (§4.7). The RFC recommends terminating explicitly with all
or redirect=
anyway — implicit defaults make debugging harder.
The rule, verbatim from §4.6.4: “SPF implementations MUST limit the total number of those terms to 10 during SPF evaluation … If this limit is exceeded, the implementation MUST return ‘permerror’.” Which terms count is fully enumerated:
| Term | Counts toward the 10? | Extra caps (§4.6.4) |
|---|---|---|
include | Yes | Nested lookups inside it also count |
a | Yes | — |
mx | Yes | ≤10 address (A/AAAA) queries per MX evaluation, else PermError |
ptr | Yes | ≤10 address queries per PTR evaluation; excess records ignored |
exists | Yes | — |
redirect | Yes | — |
ip4 , ip6 , all | No | Never query DNS |
exp | No | Looked up later, only on Fail — explicitly exempt |
There is a second, separate cap most references skip: void lookups. Queries that return no answer (RCODE 0 with zero answers, or NXDOMAIN) SHOULD be limited to two, with two as the RECOMMENDED default — and “exceeding the limit produces a ‘permerror’ result” (§4.6.4). A record full of stale includes can PermError on void lookups long before it reaches ten total.
If your own record is over the limit, the step-by-step reduction path lives in fix SPF too many DNS lookups.
A domain name “MUST NOT have multiple records that would cause an authorization check to select more than one record” (§3.2) — and the consequence is explicit, not vague: when record selection finds more than one v=spf1
record, check_host() “produces the ‘permerror’ result” (§4.5). Two SPF records don’t merge, don’t race, and don’t half-work. They fail authentication outright. If you have two, merge the mechanisms into one record.
“SPF records MUST be published as a DNS TXT (type 16) Resource Record (RR) only” (§3.1). The dedicated SPF record type (type 99) from SPF’s experimental phase was dropped by the SPFbis working group, which concluded the dual-record-type model “was fundamentally flawed” (§3.1).
This is the rule the SERP most consistently gets wrong. The 255-octet maximum applies to a single character-string inside a TXT record — not to the record. A TXT record can hold multiple quoted strings, and §3.3 is unambiguous: “If a published record contains multiple character-strings, then the record MUST be treated as if those strings are concatenated together without adding spaces.” So this:
example.com. IN TXT "v=spf1 ip4:192.0.2.0/24 include:_spf.exam" "ple.com -all"
evaluates as v=spf1 ip4:192.0.2.0/24 include:_spf.example.com -all
— note the split lands mid-token and concatenation heals it, because no space is inserted. That is the correct way to publish a record longer than 255 characters. The practical ceiling is elsewhere: §3.4 recommends keeping the whole DNS answer small enough to fit a 512-octet UDP packet (roughly 450 octets of name-plus-records), since oversized answers can be silently dropped by middleboxes that mishandle DNS over TCP.
Four annotated records, from minimal to macro-based. Lookup counts were computed by walking each chain with dig +short TXT
on July 28, 2026 — provider records change (Google flattened _spf.google.com
from 4 lookups to 1 in December 2025), so always re-verify rather than trust a blog’s cached number.
1. Single provider (Google’s own published string — Set up SPF):
v=spf1 include:_spf.google.com ~all
Reads as: authorize Google’s servers; softfail everything else. Cost: 1 lookup — _spf.google.com
now resolves to a flat ip4
/ip6
list.
2. Provider plus your own mail server:
v=spf1 ip4:192.0.2.10 mx include:_spf.google.com -all
Reads as: authorize one fixed IP, your MX hosts, and Google; fail everything else. Cost: 2 lookups (mx
, include
— the ip4
is free).
3. Multi-SaaS — Google plus Microsoft 365 (a combination Google’s doc publishes verbatim):
v=spf1 include:_spf.google.com include:spf.protection.outlook.com ~all
Reads as: authorize both providers; softfail the rest. Cost: 2 lookups — both includes currently resolve to flat address lists.
4. Macro-based dynamic authorization (Salesforce’s live record for _spf.salesforce.com
):
v=spf1 exists:%{i}._spf.mta.salesforce.com -all
Reads as: for each connection, ask whether ._spf.mta.salesforce.com
has an A record; pass if yes, fail otherwise. One exists
lookup authorizes an entire fleet without listing a single address — the §5.7 pattern running in production.
Want to assemble your own? The SPF generator builds a record from your providers, and the SPF checker validates the result against every rule on this page.
Genuine syntax errors are rarer than vendor marketing implies — and the honest numbers are worth stating. The largest peer-reviewed study of SPF configuration found that only 2.9% of 7,251,736 SPF-enabled domains had any error at all, and true syntax mistakes were 18.15% of those errors — roughly 0.5% of all SPF-enabled domains (Czybik et al., ACM IMC 2023, arxiv.org/abs/2502.08240). Keep the categories separate: syntax errors, lookup-limit PermErrors, and permissive policy choices are three different problems.
The mistakes that do occur are consistent. Each row below is an observed error class with its share of syntax errors where the IMC 2023 study measured it:
| Mistake | Wrong | Right | Rule |
|---|---|---|---|
ipv4 instead of ip4 (11.0% of syntax errors, IMC 2023) | ipv4:192.0.2.0/24 | ip4:192.0.2.0/24 | §5.6 defines ip4 /ip6 |
| Space after the colon (16.6%, IMC 2023) | include: example.com | include:example.com | §4.6.1 ABNF permits none |
= where : belongs (Microsoft-documented common error) | ip4=192.0.2.0 / include=example.com | ip4:192.0.2.0 / include:example.com | Mechanisms take : ; only modifiers take = (§4.6.1) |
More than one v=spf1 record (15.3%, IMC 2023) | Two TXT records | One merged record | §3.2 / §4.5 → PermError |
Terms after all | v=spf1 -all include:... | v=spf1 include:... -all | §5.1 — silently ignored |
| SPF concatenated with a verification string (7.0%, IMC 2023) | v=spf1 ...google-site-verification=... | Separate TXT records | §4.5 selection breaks |
The =
-for-:
confusion is documented by Microsoft among the most common errors its support teams see, alongside stray spaces and trailing periods (Microsoft SPF doc, accessed 2026-07-28).
If your DMARC reports already show PermError and you need the diagnosis path rather than the reference, that flow lives in fix SPF PermError.
No. RFC 7208 permits exactly one SPF record per domain name (§3.2), and when record selection finds more than one v=spf1
TXT record, the result is PermError (§4.5) — authentication fails for every message. Merge the mechanisms from both records into a single v=spf1
record instead.
include:
matches only when the referenced record returns Pass, and evaluation continues if it doesn’t (§5.2). redirect=
applies only after every mechanism misses, replaces your record’s result entirely with the referenced domain’s, and is ignored whenever an all
mechanism is present (§6.1).
Per RFC 7208 §4.6.4, the terms that count are include
, a
, mx
, ptr
, and exists
, plus the redirect
modifier. The ip4
, ip6
, and all
mechanisms and the exp
modifier never query DNS during evaluation and are exempt. A separate cap of two void lookups also applies.
%{i}
is a macro that expands to the connecting client’s IP address — dotted-quad format for IPv4, dot-separated nibble format for IPv6 (§7.2, §7.3). It is most often paired with exists:
to build per-IP hostnames, letting one lookup authorize a dynamic set of senders.
Yes. Mechanisms are evaluated left to right, the first match stops processing, and that mechanism’s qualifier becomes the result (§4.6.2). An early -
qualifier is final for any IP it matches — no later include
can override it — and terms after all
are never evaluated (§5.1).
Split it into multiple quoted strings of at most 255 bytes each, inside one TXT record. Receivers must concatenate the strings without adding spaces (§3.3), so the split can even fall mid-token. Keep the full DNS answer under roughly 450 octets so it fits a single UDP packet (§3.4).
SPF record syntax rewards precision, and the whole standard reduces to a short list worth keeping:
v=spf1
TXT record per name; a second one is a PermError, not a merge (§3.1, §3.2).And when you’re ready to see how your record performs against real mail flows, DMARCguard’s aggregate reports show which sources pass, which fail, and exactly what to change.