# 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.

Source: emailmarketing.net — https://emailmarketing.net/learn/authentication/spf

SPF lets you publish, in DNS, which hosts may send mail using your domain. Receivers compare the IP address of the connecting client with that list. SPF checks the **RFC5321.MailFrom** identity (the "Envelope From", or `Return-Path`) and, separately, the identity given in **HELO or EHLO**.

SPF is defined in RFC 7208. It authenticates the path the message took, through a DNS TXT record published by the domain. For how SPF feeds into DMARC alignment, see [DMARC](https://emailmarketing.net/learn/authentication/dmarc).

## Identities checked

- Verifiers **MUST** check the `MAIL FROM` identity if a HELO check was not performed or did not reach a definite result.
- It is **RECOMMENDED** that verifiers also check the `HELO` identity separately. When `MAIL FROM` is empty (bounces, `MAIL FROM:<>`), the HELO domain is used as the MAIL FROM domain (`postmaster@<HELO domain>`).

## The record

- The record is published as a DNS **TXT** record at the domain being checked. It **must begin exactly** with `v=spf1` (e.g., `v=spf10` does not match and is discarded). RFC 7208 deprecated the separate SPF record type in DNS (type 99), so publish TXT only.
- If **no** `v=spf1` record exists, the result is **none**. If **more than one** exists, the result is **permerror**.
- Terms are evaluated **from left to right**, and the **first mechanism that matches** decides the result through its qualifier. If nothing matches and there is no `redirect=`, 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 | Meaning | DNS lookup? |
|---|---|---|---|
| `all` | `all` | Always matches. Placed last as the explicit default (`-all`, `~all`, …). Anything after `all` is ignored. | No |
| `include` | `include:<domain>` | Evaluates the SPF record of `<domain>` recursively. If the recursive result is **pass**, the mechanism matches. If it is **fail**, **softfail** or **neutral**, the mechanism does not match and evaluation continues. **temperror** and **permerror** are passed up. A recursive **none** gives **permerror**. | Yes |
| `a` | `a[:<domain>][/<cidr4>][//<cidr6>]` | Matches if the client IP address equals an A (IPv4) or AAAA (IPv6) record of the target domain (by 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, and matches if the client IP address is among them. | Yes |
| `ptr` | `ptr[:<domain>]` | A validated reverse DNS check. **"This mechanism SHOULD NOT be published"**: it is slow, unreliable and deprecated in practice. | Yes |
| `ip4` | `ip4:<network>[/<cidr>]` | The client IP address is within the IPv4 network. The default prefix is `/32`. | No |
| `ip6` | `ip6:<network>[/<cidr>]` | The client IP address is within the IPv6 network. The default prefix is `/128`. | No |
| `exists` | `exists:<domain-spec>` | Expands macros in the domain and does an **A** lookup (even for IPv6 connections). Matches if any A record is returned. This allows a policy for each IP address through macros. | Yes |

CIDR prefix lengths: `/0`–`/32` for IPv4 (default `/32`), and `/0`–`/128` for IPv6 (default `/128`). Only the given number of high-order bits is compared.

## Modifiers

Modifiers are pairs of a name and a value (name=value). Each may appear at most once, anywhere in the record.

| Modifier | Syntax | Meaning |
|---|---|---|
| `redirect` | `redirect=<domain>` | If **no mechanism matched**, evaluation continues with the SPF record of `<domain>`, and its result is used unchanged. If the redirect target has no SPF record, the result is **permerror** (not none). It counts toward the 10-lookup limit. It is ignored when the record also contains `all` (since `all` always matches first). |
| `exp` | `exp=<domain>` | On a **fail** result, a TXT record is looked up at the domain, its macros are expanded, and it is returned as the explanation for people to read. It does not count toward the 10-lookup limit. |

## Macros

Macros (`%{x}`) can be expanded in `domain-spec` fields and in `exp` text:

| Macro | Expands to |
|---|---|
| `%{s}` | Sender (the full MAIL FROM address) |
| `%{l}` | Local part of the sender |
| `%{o}` | Domain of the sender |
| `%{d}` | Current domain being checked |
| `%{i}` | Client IP address (dotted quad for IPv4; nibbles separated by dots for IPv6) |
| `%{p}` | Validated reverse DNS domain of the client IP address (SHOULD NOT be used, for the same reasons as `ptr`) |
| `%{v}` | `in-addr` for IPv4, `ip6` for IPv6 |
| `%{h}` | The domain given in HELO or EHLO |
| `%%` / `%_` / `%-` | Literal `%` / space / URL-encoded space (`%20`) |

Transformers: a digit limits how many labels are kept, counting from the right (`%{d2}` keeps the last two labels), `r` reverses the order of labels, and other delimiter characters may follow.

## Result codes

| Result | Meaning (RFC 7208 §2.6) | Recommended handling by the receiver (§8) |
|---|---|---|
| **none** | No valid domain was extracted, or no SPF record was found | No information; inconclusive |
| **neutral** | The domain explicitly says nothing about the IP address (`?`) | **MUST** be treated exactly like `none` |
| **pass** | The client is authorized to send mail for the domain | The domain is accountable; proceed |
| **fail** | The client is explicitly **not** authorized (`-`) | Local policy. If rejecting, use SMTP **550** with enhanced status **5.7.1** |
| **softfail** | The host is probably not authorized (`~`), and the domain is in transition | **SHOULD NOT** reject on this alone; **MAY** look more closely |
| **temperror** | A temporary error (usually DNS) during evaluation | Accept or defer. If deferring, use SMTP **451** with status **4.4.3** |
| **permerror** | The published record could not be interpreted correctly (the DNS operator must fix it) | If rejecting, use SMTP **550** with status **5.5.2** |

A note for DMARC: `fail`, `softfail`, `neutral`, `none` and both errors all count as "not pass". Only **pass** (with alignment) can satisfy the SPF side of DMARC.

## DNS lookup limits (§4.6.4)

| Limit | Value | When exceeded |
|---|---|---|
| Terms that query DNS in one evaluation (`include`, `a`, `mx`, `ptr`, `exists`, `redirect`) | **10 total**, counted across all recursion through `include` and `redirect` | **permerror** |
| "Void lookups" (NXDOMAIN or an empty answer) | SHOULD be limited to **2** | **permerror** |
| Address lookups for each `mx` mechanism | **10** MX names | **permerror** |
| Address lookups for each `ptr` evaluation | **10** PTR names | Records beyond the first 10 are **ignored** |

`all`, `ip4`, `ip6` and `exp` do **not** count toward the 10-term limit.

## Common pitfalls

- **More than 10 lookups.** Nested `include` chains from ESPs, CRMs and ticketing tools add up fast. The result is permerror, which DMARC treats as having no SPF at all. Flatten includes or remove vendors you no longer use, and prefer `ip4` or `ip6` (which cost no lookups) to `a` or `mx` where practical.
- **Several `v=spf1` records** at one name give permerror. Merge them into a single record.
- **`+all` (or a missing or permissive default)** authorizes the entire internet, which is worse than having no record. End records with `-all` or `~all`.
- **An `include` of a domain with no SPF record** gives permerror (a recursive none). Check vendor includes when you stop using a service.
- **The `ptr` mechanism and the `%{p}` macro** are deprecated, slow and unreliable. Do not publish them.
- **Forwarding breaks SPF.** The forwarder's IP address is not in the original domain's record, so SPF fails after any hop that keeps the original MAIL FROM. This is built into authentication by path. It is why DKIM, which authenticates the content, is preferred for passing DMARC after forwarding, and why [ARC](https://emailmarketing.net/learn/authentication/arc) exists.
- **An SPF pass is not a DMARC pass.** Many ESPs use their own bounce domain in MAIL FROM, so SPF passes but is not aligned with the From: domain. For alignment, use a custom Return-Path (bounce) subdomain of the From: domain. See [DMARC](https://emailmarketing.net/learn/authentication/dmarc).
- **TXT strings longer than 255 characters** must be split into several quoted strings within the single record (they are joined together). Large records may also force DNS over TCP.
- **A `redirect=` after `all`** never takes effect, because `all` matches first.

## Related articles

- [DKIM](https://emailmarketing.net/learn/authentication/dkim), which authenticates the content instead
- [DMARC](https://emailmarketing.net/learn/authentication/dmarc), on how SPF results and alignment feed into policy
- [ARC](https://emailmarketing.net/learn/authentication/arc), on preserving authentication results across forwarding
