SPF (Sender Policy Framework)
RFC 7208 reference — record syntax (mechanisms, qualifiers, modifiers, macros), the check_host() evaluation algorithm, DNS lookup limits, result codes, and common pitfalls.
SPF (RFC 7208) is a path-based authentication protocol: a domain publishes a DNS TXT record declaring which hosts are authorized to use the domain in the RFC5321.MailFrom identity (the "Envelope From" / Return-Path) and, separately, in the HELO/EHLO identity. Receivers evaluate the connecting client IP against that record.
For how SPF feeds into DMARC alignment, see DMARC.
Identities checked
- Verifiers MUST check the
MAIL FROMidentity if a HELO check has not been performed or did not reach a definitive result. - It is RECOMMENDED that verifiers also separately check the
HELOidentity. WhenMAIL FROMis empty (bounces,MAIL FROM:<>), the HELO domain is used as the MAIL FROM domain (postmaster@<HELO domain>).
The record
- Published as a DNS TXT record at the domain being checked. The record must begin exactly with
v=spf1(e.g.,v=spf10does not match and is discarded). The separate SPF DNS RR type (99) was deprecated by RFC 7208 — publish TXT only. - If zero
v=spf1records exist → result none. If more than one exists → permerror. - Terms are evaluated left to right; the first matching mechanism determines the result via its qualifier. If nothing matches and no
redirect=is present, the default result is neutral (an implicit?all).
Qualifiers
| Qualifier | Result when the mechanism matches |
|---|---|
+ (default if omitted) |
pass |
- |
fail |
~ |
softfail |
? |
neutral |
Mechanisms
| Mechanism | Syntax | Semantics | DNS lookup? |
|---|---|---|---|
all |
all |
Always matches. Used rightmost as the explicit default (-all, ~all, …). Anything after all is ignored. |
No |
include |
include:<domain> |
Recursively evaluates <domain>'s SPF record. Recursive pass → the mechanism matches; fail/softfail/neutral → does not match (evaluation continues); temperror/permerror propagate; recursive none → permerror. |
Yes |
a |
a[:<domain>][/<cidr4>][//<cidr6>] |
Matches if the client IP equals an A (IPv4) or AAAA (IPv6) record of the target domain (default: the current domain), optionally within a CIDR prefix. | Yes |
mx |
mx[:<domain>][/<cidr4>][//<cidr6>] |
Looks up the target's MX records, then the address records of each MX name; matches if the client IP is among them. | Yes |
ptr |
ptr[:<domain>] |
Validated reverse-DNS check. "This mechanism SHOULD NOT be published" — slow, unreliable, deprecated in practice. | Yes |
ip4 |
ip4:<network>[/<cidr>] |
Client IP within the IPv4 network. Default prefix /32. |
No |
ip6 |
ip6:<network>[/<cidr>] |
Client IP within the IPv6 network. Default prefix /128. |
No |
exists |
exists:<domain-spec> |
Macro-expands the domain, does an A lookup (even for IPv6 connections); matches if any A record is returned. Enables per-IP policies via macros. | Yes |
CIDR prefix lengths: IPv4 /0–/32 (default /32), IPv6 /0–/128 (default /128). Only the specified number of high-order bits is compared.
Modifiers
Modifiers are name=value pairs; each may appear at most once anywhere in the record.
| Modifier | Syntax | Semantics |
|---|---|---|
redirect |
redirect=<domain> |
If no mechanism matched, evaluation continues with the SPF record of <domain>, whose result is used as-is. If the redirect target has no SPF record, the result is permerror (not none). Counts toward the 10-lookup limit. Ignored when the record also contains all (since all always matches first). |
exp |
exp=<domain> |
On a fail result, the domain is queried for a TXT record, macro-expanded, and returned as the human-readable explanation string. Does not count toward the 10-lookup limit. |
Macros
Macro expansion (%{x}) is available in domain-spec fields and exp text:
| Macro | Expands to |
|---|---|
%{s} |
Sender (full MAIL FROM address) |
%{l} |
Local-part of sender |
%{o} |
Domain of sender |
%{d} |
Current domain being checked |
%{i} |
Client IP (dotted quad for IPv4; dot-separated nibbles for IPv6) |
%{p} |
Validated reverse-DNS domain of client IP (SHOULD NOT be used — same problems as ptr) |
%{v} |
in-addr for IPv4, ip6 for IPv6 |
%{h} |
HELO/EHLO domain |
%% / %_ / %- |
Literal % / space / URL-encoded space (%20) |
Transformers: a digit limits the number of right-hand labels kept (%{d2} → last two labels); r reverses label order; alternate delimiter characters may follow.
Result codes
| Result | Meaning (RFC 7208 §2.6) | Recommended receiver handling (§8) |
|---|---|---|
| none | No valid domain extracted, or no SPF record found | No information; inconclusive |
| neutral | Domain explicitly asserts nothing about the IP (?) |
MUST be treated exactly like none |
| pass | Client is authorized to inject mail for the domain | Domain is accountable; proceed |
| fail | Client is explicitly not authorized (-) |
Local policy; if rejecting, SMTP 550, enhanced status 5.7.1 |
| softfail | Host is probably not authorized (~); domain is transitioning |
SHOULD NOT reject solely on this; MAY apply closer scrutiny |
| temperror | Transient error (usually DNS) during evaluation | Accept or defer; if deferring, SMTP 451, status 4.4.3 |
| permerror | Published record could not be correctly interpreted (requires DNS operator fix) | If rejecting, SMTP 550, status 5.5.2 |
Note for DMARC: fail, softfail, neutral, none, and both errors all count as not pass — only pass (with alignment) can satisfy DMARC's SPF leg.
DNS lookup limits (§4.6.4)
| Limit | Value | On exceeding |
|---|---|---|
DNS-querying terms per evaluation (include, a, mx, ptr, exists, redirect) |
10 total, counted across all recursion via include/redirect |
permerror |
| "Void lookups" (NXDOMAIN or empty answer) | SHOULD limit to 2 | permerror |
Address lookups per mx mechanism |
10 MX names | permerror |
Address lookups per ptr evaluation |
10 PTR names | Records beyond the first 10 ignored |
all, ip4, ip6, and exp do not count toward the 10-term limit.
Common pitfalls
- Exceeding 10 lookups. Nested
includechains from ESPs, CRMs, and ticketing tools add up fast; the result is permerror, which DMARC treats as no SPF at all. Flatten includes or trim unused vendors; preferip4/ip6(zero-cost) overa/mxwhere practical. - Multiple
v=spf1records at one name → permerror. Merge into a single record. +all(or a missing/permissive default) authorizes the entire internet — worse than no record. End records with-allor~all.includeof a domain with no SPF record → permerror (recursive none). Audit vendor includes when offboarding services.ptrmechanism /%{p}macro — deprecated, slow, and unreliable; do not publish.- Forwarding breaks SPF: the forwarder's IP isn't in the origin domain's record, so SPF fails after any hop that preserves the original MAIL FROM. This is inherent to path-based auth — it's why DKIM (content-based) is preferred for DMARC survival, and why ARC exists.
- SPF pass ≠ DMARC pass: many ESPs use their own bounce domain in MAIL FROM, so SPF passes but is not aligned with the From: domain. Use a custom Return-Path (bounce) subdomain of the From: domain for alignment — see DMARC.
- TXT strings over 255 characters must be split into multiple quoted strings within the single record (they are concatenated); large records may also force TCP DNS.
redirect=afterallis dead code —allmatches first.