# SPF record explained with a real site: what each entry authorizes, and the IPv6 trap

> SPF record syntax read entry by entry on a real site: include, ~all softfail vs -all hardfail, 2 of 10 DNS lookups, and the IPv6 gap one test would miss.

Source: https://visibleorigin.com/spf-record-explained/
Published: 2026-10-24

---

**The short version.** An SPF record is a TXT record listing the servers allowed to put your domain in the envelope of an email: the hidden MAIL FROM address, not the From line people read. Receivers check it left to right, and the check may cost at most ten DNS lookups. I took this site’s own record apart: five terms, two lookups, 95,233 IPv4 addresses authorized, one of them my server. The obvious fix for its old softfail, adding the server’s IPv4 address, would have left its mail to Gmail unauthorized whenever it went out over IPv6. And its two provider includes cover companies that, by their own documentation, put their own domain in that envelope.

![This site’s SPF record split into its five entries, with the addresses each one authorizes and the DNS lookups it costs](https://visibleorigin.com/wp-content/uploads/spf-record-five-entries.webp)Five terms and two DNS lookups. Almost every address this record authorizes belongs to the two providers, not to the server that sends the site’s own mail.

Most explanations of an SPF record stop at the syntax: a version tag, a few mechanisms and an ending. The syntax is the easy part, and any checker will tell you whether yours parses. What a checker cannot tell you is what each entry of your record does for the mail you actually send: which machines it lets in, what it costs, and whether that mail ever presents your domain to be checked against it at all.

So this note takes a real record, the one published for visibleorigin.com, and goes through it one entry at a time. Everything in it was measured on this domain and on the server behind it between August and October 2026, and the commands are included so you can run them against your own domain. Where the server’s own addresses would appear, I use addresses reserved for documentation instead: `192.0.2.10` from the IPv4 blocks of RFC 5737 and `2001:db8::10` from the IPv6 prefix of RFC 3849. Everything else is exactly as published.

## What an SPF record is, according to the standard

SPF is defined by RFC 7208, and its definition is shorter than most tutorials: “An SPF record is a DNS record that declares which hosts are, and are not, authorized to use a domain name for the ‘HELO’ and ‘MAIL FROM’ identities.” It lives in DNS as a TXT record (the standard says SPF records “MUST be published as a DNS TXT (type 16) Resource Record (RR)”), and it is **the one TXT record at your domain that begins with `v=spf1`**.

The words that matter most are “MAIL FROM.” That is the **envelope sender**: the address a sending server gives in the SMTP conversation, before the message itself is transmitted. After delivery it usually appears in the message as the `Return-Path` header. It is not the From line your recipient reads, and the two can name different domains. SPF never looks at the visible From. DMARC is what ties the two together, and its specification, RFC 9989, is explicit about which identity it takes from SPF: “DMARC relies solely on SPF validation of the MAIL FROM identity.” Most of the surprises later in this note come from that sentence.

The check itself is a function the standard calls `check_host()`. A receiving server gives it the IP address of the server that connected, the domain in MAIL FROM and the sender address, and the function reads that domain’s record in order: “each mechanism is considered in turn from left to right.” **The first entry that matches decides the result**, through the qualifier in front of it: `+` (the default, almost always left out) means pass, `-` fail, `~` softfail and `?` neutral. If nothing matches and the record has neither an `all` nor a `redirect`, the result is neutral. These are the results you will see in an `Authentication-Results` header:

| Result | What it says | When you get it |
| --- | --- | --- |
| `pass` | The connecting server is authorized | An entry matched with `+`, written or implied |
| `fail` | It is explicitly not authorized | An entry matched with `-`, usually `-all` |
| `softfail` | It is probably not authorized | An entry matched with `~`, usually `~all` |
| `neutral` | The domain makes no statement | `?all`, or nothing matched and there is no `all` |
| `none` | There is nothing to check | No record at the domain begins with `v=spf1` |
| `permerror` | The record is broken | Two SPF records, more than ten DNS lookups, or a syntax error |
| `temperror` | Try again later | A DNS failure while the record was being checked |

The results as RFC 7208 defines them in section 2.6. Softfail is the one this site produced on September 9, 2026, and most of this note is about why.

## The SPF record of this site, entry by entry

This is what a public resolver returns for the domain today, with the server’s two addresses replaced by documentation addresses and the two verification codes left out:

```
$ dig +short TXT visibleorigin.com @1.1.1.1
"brevo-code:(verification code)"
"google-site-verification=(verification code)"
"v=spf1 ip4:192.0.2.10 ip6:2001:db8::10 include:_spf.mx.cloudflare.net include:spf.brevo.com ~all"
```

There are three TXT records at the top of the domain, and only one is an SPF record. The other two prove ownership of the domain to the email provider and to Google, which is harmless, because the standard tells the receiver to “discard records that do not begin with a version section of exactly ‘v=spf1’.” A second record that does begin with `v=spf1` is another matter, and I come back to it below.

After the version tag, the record has five terms. On October 22, 2026, I resolved each include against a public resolver, counted the lookups and added up the addresses each term authorizes:

| Entry | What it authorizes, and its DNS cost |
| --- | --- |
| `ip4:192.0.2.10` | This server, over IPv4: one address. No lookup. |
| `ip6:2001:db8::10` | This server, over IPv6: one address. No lookup. |
| `include:_spf.mx.cloudflare.net` | Cloudflare Email Routing: 8,192 IPv4 addresses and one IPv6 /38. One lookup. |
| `include:spf.brevo.com` | Brevo, the relay that sends the site’s mail: 87,040 IPv4 addresses and no IPv6. One lookup. |
| `~all` | Everyone else: softfail. No lookup. |

Measured on October 22, 2026, against 1.1.1.1. Neither included record contains a further include, so the whole check costs two of the ten lookups allowed, and none of them comes back empty.

Add those up and **the record authorizes 95,233 IPv4 addresses, and one of them is my server**. The other 95,232 belong to the shared sending networks of two companies, and so does an IPv6 block of 290 addresses. That is not a mistake in itself; authorizing a provider’s network is what an include does. It is, however, the number RFC 9989 asks domain owners to look at. Its section on SPF notes that “historically lax enforcement of such policies has led many to publish extremely broad records containing many extensive network ranges,” and adds that “Domain Owners should periodically review their SPF records to ensure that the authorization conveyed by the records matches the domain’s current needs.” Doing that review for this record produced the second half of this note.

### What an include really does

Brevo’s own record ends in `-all`, a hard fail, while mine ends in `~all`. That does not make my record fail every address Brevo does not list, because **an include does not paste the other record into yours**. The RFC is unusually candid about it: “In hindsight, the name ‘include’ was poorly chosen. Only the evaluated result of the referenced SPF record is used, rather than literally including the mechanisms of the referenced record in the first.” An include matches when the other record returns pass; otherwise evaluation moves on. When I ran a minimal implementation of `check_host()` against my record with another documentation address, `203.0.113.7`, as the connecting server, the result was softfail, decided by my own `~all`, not fail from Brevo’s.

### Size, and a signature

The real record is 103 characters long, well inside “the 255-octet maximum length of a character-string within a single TXT record” that RFC 7208 mentions. Its size guideline counts more than the SPF record: “If the size of the DNS message, the combined length of the DNS name and the text of all the records of a given type is under 450 octets, then DNS answers ought to fit in UDP packets.” For this domain, the name plus all three TXT records come to 231 octets, and the whole DNS response was 299 bytes. The zone is also signed with DNSSEC, and the answer came back with the `ad` flag, which I explained in [the note on publishing a DNS-AID SVCB record](https://visibleorigin.com/dns-aid-svcb-record/).

### Three versions in two months

The record did not start like this. It has had three versions since the site launched, and I nearly published a fourth. For mail sent by the server itself, this is what `check_host()` returns under each one:

| Version of the record | Server’s mail over IPv4 | Over IPv6 |
| --- | --- | --- |
| At launch, until August 30: `v=spf1 -all` | fail | fail |
| From August 30: the two providers only | softfail | softfail |
| Never published: the providers plus `ip4` | pass | softfail |
| Since September 9: the providers plus `ip4` and `ip6` | pass | pass |

check_host() for this server’s two addresses under each version, computed on October 22, 2026, with the includes resolved live. The third row is the fix I nearly published.

At launch the domain sent no mail, and `v=spf1 -all` said exactly that: nobody may use this domain in an envelope. Next to a DMARC policy of `p=reject`, it also meant that any notice the site tried to send would be rejected without anyone seeing it. So on August 30 the record changed to authorize the two providers, and **I left the server out on purpose**. All of the site’s mail was supposed to leave through Brevo, and authorizing the server would have made a compromise of it worse. That reasoning had one flaw, and it took a test message to find it.

## The softfail, and the IPv6 trap behind the obvious fix

By early September, a DMARC aggregate report had shown five messages from this domain rejected. They were mail the server sends on its own, such as cron output and system notices, and they had two separate problems. The DKIM key the server signs with had been generated but never published in DNS; that half of the story, including how OpenDKIM’s own test tools misreported it, is in [the note on the two OpenDKIM tools that misreport](https://visibleorigin.com/opendkim-tools-misreport/). The other half was the SPF record, which **did not list the server**: I had left it out on purpose and then forgotten that it sends mail of its own.

On September 9, after the DKIM key was published, I sent a test message through the server’s own mail system to `check-auth@verifier.port25.com`, a free verifier that replies with an authentication report. The report said:

```
SPF check:          softfail
"iprev" check:      pass
DKIM check:         pass
```

That was exactly what the record predicted: the server’s address matched no entry, so the last one, `~all`, decided. The obvious repair is to add the server’s IPv4 address. **Before publishing it, I checked which addresses the server actually sends from**, and that is where the trap was.

### The server has two addresses, and Postfix uses both

This server has a global IPv6 address as well as its IPv4 one, and its mail system, Postfix, is allowed to use both. One command shows the two settings that decide it:

```
$ postconf -h inet_protocols smtp_address_preference
all
any
```

The Postfix documentation defines `smtp_address_preference` as “the address type (‘ipv6’, ‘ipv4’ or ‘any’) that the Postfix SMTP client will try first, when a destination has IPv6 and IPv4 addresses with equal MX preference.” With `any` there is no fixed preference, and the same page explains why that is the safe default: with it, “only half of deliveries will suffer delays if there is an outage that affects IPv6 or IPv4.” In other words, **deliveries are spread across both families on purpose**. Gmail is one of the destinations where that applies, because its primary mail host, the first one its MX records name, publishes both kinds of address:

```
$ dig +short AAAA gmail-smtp-in.l.google.com @1.1.1.1
2607:f8b0:4004:c23::1a
$ dig +short A gmail-smtp-in.l.google.com @1.1.1.1
172.253.139.27
```

So the question was not hypothetical. I went back to the mail log this server keeps, which goes back to September 20, and counted every delivery it made to Google’s mail servers, by address family:

```
$ zgrep -hoE 'relay=[^ ,]*google\.com\[[^]]*\]' /var/log/maillog* \
    | sed -E 's/.*\[(.*)\]/\1/' \
    | awk '{print (index($0,":") ? "IPv6" : "IPv4")}' | sort | uniq -c
      4 IPv4
      1 IPv6
```

All five were test messages sent on October 4, delivered within three minutes to the same Gmail host, `gmail-smtp-in.l.google.com`: four over IPv4, then one over IPv6. **Same server, same software, same destination, and both address families.** Five deliveries are not a statistic, and I would not quote a ratio from them. They do settle the question that matters: this server sends to Gmail over IPv6 as well.

![A server with IPv4 and IPv6 addresses: an ip4-only record passes deliveries over IPv4 and softfails those over IPv6](https://visibleorigin.com/wp-content/uploads/spf-ipv4-ipv6-two-ways-out.webp)Postfix picks an address family for each delivery, and an ip4 entry cannot match an IPv6 connection. A test that leaves over IPv4 never sees the gap.

### Why an IPv4 entry cannot match an IPv6 delivery

RFC 7208 defines the `ip4` and `ip6` mechanisms in one sentence: “These mechanisms test whether <ip> is contained within a given IP network.” An IPv6 address is never inside an IPv4 network, so **an `ip4` entry can only match a message that arrived over IPv4**. With the IPv4-only fix, every delivery that left over IPv6 would have reached the end of the record without a match and taken the `~all`: softfail, the same result the test had just shown, for exactly the deliveries my test had not made.

### Why nobody would have noticed for months

Had I published that fix, the gap would not have bounced anything. DMARC passes when SPF or DKIM passes for a domain aligned with the visible From, and the server’s DKIM signature was now valid, so messages that left over IPv6 would have arrived with SPF softfail and DKIM pass, and passed DMARC on DKIM alone. The record would have looked fixed, a test that happened to leave over IPv4 would have come back green, and the hole would have stayed open until the day DKIM failed on its own: a key rotation gone wrong, or the signing service stopped. From that day, with DMARC at `p=reject`, **mail over IPv4 would still have passed and mail over IPv6 would have failed**, decided by a choice Postfix makes for each delivery. That is the kind of fault that gets described as “email is flaky” for weeks.

### What Gmail asks of a server that sends over IPv6

Authorizing the IPv6 address in SPF is only half of what Gmail asks for. Google’s sender guidelines require of every sender: “Set up SPF or DKIM email authentication for your sending domains,” and “Ensure that sending domains or IPs have valid forward and reverse DNS records, also referred to as PTR records.” They spell out what valid means: “The public IP address of a sending SMTP server must have a corresponding PTR record that resolves to a hostname,” and “The same hostname must also have an A (for IPv4) or AAAA (for IPv6) record that resolves to the same public IP address used by the sending server.” **Over IPv6, Gmail has a rejection of its own**, which the same page quotes as an example: “550-5.7.1: Message does not meet IPv6 sending guidelines regarding PTR records and authentication.”

So before the IPv6 address went into the record, I checked that its reverse DNS closes the circle: **the address points to a hostname, and the hostname points back to the same address**. It does for both addresses, and it still did when I checked again for this note:

```
$ dig +short -x 2001:db8::10 @1.1.1.1
host.example.net.
$ dig +short AAAA host.example.net @1.1.1.1
2001:db8::10
```

That output uses a documentation address and a placeholder name; on the real server, both addresses return the same hostname, and the hostname returns both addresses. Only then did the record get its two new entries. A second test, an hour and a half after the first, came back with all three checks green:

```
SPF check:          pass
"iprev" check:      pass
DKIM check:         pass
```

On October 4, the headers of a message this server sent straight to Gmail showed `spf=pass`, `dkim=pass` and `dmarc=pass`. That message is the one in the log above with my domain on it, and it left over IPv4, so **it proves the IPv4 entry and nothing more**. For the IPv6 entry, the evidence is the record itself, the result of `check_host()` for that address and its reverse DNS, not a delivery I have watched arrive.

### The other way out, which I did not take

The other fix was to stop the server from sending over IPv6 at all. Postfix’s documentation warns that “The setting ‘smtp_address_preference = ipv4’ is not a solution for remote servers that flag email received over IPv6 as more ‘spammy’,” and advises instead to “configure Postfix to receive mail over both IPv4 and IPv6, and to deliver mail over only IPv4,” with an `inet_protocols=ipv4` override on the delivery service in `master.cf`. I did not need it: the IPv6 address already met Gmail’s reverse DNS requirement, so authorizing it was a one-line DNS change instead of a change to how the server delivers mail.

### One test proves one path

The lesson I took from this is not really about IPv6. **A test message takes one path, so it can only vouch for the path it took.** The port25 report was accurate about the message it received, and under an IPv4-only record, a test that left over IPv4 would have been just as accurate, and just as green, while some of my mail still failed. It is the same mistake I made the day [php -i described a different PHP from the one serving my site](https://visibleorigin.com/php-i-is-not-your-site/): a correct answer about a different thing. Before trusting a test, find out which path it took, and which paths it did not.

## Whose SPF record a receiver actually checks

Reviewing the record against the mail this site really sends turned up a second, quieter problem, and it follows from the sentence in RFC 9989 quoted at the start. A receiver checks the SPF record of the domain in the envelope, so for every path your mail takes, the question is whose domain is in that envelope. This site’s mail takes three paths, and **only one of them presents my domain to be checked**.

![Three mail paths, from the server, through Brevo and through Cloudflare forwarding, and whose record each envelope meets](https://visibleorigin.com/wp-content/uploads/spf-whose-record-gets-checked.webp)SPF checks the domain in the envelope. Brevo and Cloudflare put their own domains there, so only the server’s own mail is checked against this site’s record.

### The server itself: my record, and almost no mail

Mail that Postfix sends directly carries my domain in MAIL FROM, so it is checked against my record and matches the `ip4` or `ip6` entry. Since September 20, the server’s mail log shows exactly one message with my domain in the envelope leaving the machine: the test on October 4. The two entries that took the most care protect the path with the least traffic. That is fine: they exist for the day something on the server needs to send mail, and that path is ready for it.

### WordPress through Brevo: their record, and DKIM does the work

Everything WordPress sends goes to Brevo’s relay, `smtp-relay.brevo.com` on port 587, through the FluentSMTP plugin; its log currently covers October 11 to 21, with 11 messages sent and none failed. That mail is the reason `include:spf.brevo.com` is in my record, and it is the line many Brevo setup guides tell you to add. Brevo’s own help center says something different. On the page about authenticating a domain: “The SPF and MX records are not required to authenticate a domain. We only provide these records when setting up a dedicated IP.” On the page about domain setup: “When you authenticate your domain, Brevo still uses its own generic infrastructure for SPF, return-path, tracking links, and image links in your emails.” The same page puts the consequence in one line: **“Without a branded subdomain, SPF passes via Brevo’s infrastructure, not your own domain.”** This site uses Brevo’s shared relays without a branded subdomain. By Brevo’s own account, then, the envelope of my mail through Brevo carries Brevo’s domain, the receiver checks Brevo’s record, and my include is never consulted.

Google’s guidelines seem to say the opposite: “The SPF record for your domain should include all email senders for your domain. If your third-party senders aren’t included in your SPF record, messages from these senders are more likely to be marked as spam.” The way I reconcile the two is the identity rule from the start of this note. SPF only checks the domain in MAIL FROM, so **a provider that puts its own domain there is covered by its own record, and a provider that puts yours there has to be in yours**. Which kind you have is answered by the headers of a message it sent, not by its setup guide.

What makes my Brevo mail pass DMARC is DKIM. The domain publishes two DKIM keys for Brevo, `brevo1` and `brevo2`, as CNAME records that point to Brevo, and Brevo signs with my domain. When the headers of a message sent this way were read at Gmail on October 4, they showed `dkim=pass` with Brevo’s selector and `dmarc=pass`. I have not read the envelope of a Brevo message for this note, so the SPF half rests on Brevo’s documentation, not on a header I have seen. The check is easy to do on your own mail, and it is the third step of the list below.

### Mail to my address through Cloudflare: their record, by design

Mail sent to addresses at this domain arrives at Cloudflare Email Routing, which forwards it to a mailbox elsewhere. Cloudflare’s page on domain configuration lists the SPF include among the routing records it adds to the root of the domain, and gives its purpose as “Authorizes Cloudflare to forward emails on behalf of your domain.” Its postmaster page describes what happens to the envelope when it forwards: “For forwarded emails, Email Routing uses the Sender Rewriting Scheme to rewrite the envelope sender (the SMTP MAIL FROM address) to a Cloudflare-controlled forwarding domain. This rewriting allows SPF to pass at the destination server even though the message is being relayed.”

Read together, the two pages say that the include authorizes Cloudflare for my domain, and that **forwarded mail never presents my domain to be checked**; the destination checks the Cloudflare-controlled domain instead. The domain Cloudflare names for the DKIM signature that covers the rewriting, `email.cloudflare.net`, publishes the record you would expect: `v=spf1 include:_spf.mx.cloudflare.net ~all`. I have not found a path in which my own copy of that include is the one consulted.

The same page also shows why an include has to be read live. Its SPF breakdown prints the record as `v=spf1 ip4:104.30.0.0/20 ~all`, while its list of outbound prefixes gives `104.30.0.0/19` and `2405:8100:c000::/38`, which is what DNS returns today. Its own advice settles it: “To verify the current authoritative ranges, query the SPF record directly.”

### Why both includes are still there

I have not removed either include. Cloudflare lists the SPF record, next to the MX and DKIM records, among the routing records its Email Routing settings verify, and saving one lookup is not worth running a setup its documentation does not describe. The Brevo include costs one lookup and authorizes 87,040 addresses for mail that, by Brevo’s account, never uses my domain in the envelope; it is the first to go the day I need the room or read a Brevo envelope myself. The rule I take from both is that **an include earns its place only if mail through that provider carries your domain in MAIL FROM**. That is also where I start when I look at someone else’s mail in a [business email routing and deliverability review](https://visibleorigin.com/services/#6-business-email-routing-and-deliverability): not with whether the record parses, but with which of the domain’s mail paths ever consult it.

## Ten DNS lookups, and the other limits of an SPF record

The limit everyone has heard of is ten, and RFC 7208 is precise about what counts: “The following terms cause DNS queries: the ‘include’, ‘a’, ‘mx’, ‘ptr’, and ‘exists’ mechanisms, and the ‘redirect’ modifier. SPF implementations MUST limit the total number of those terms to 10 during SPF evaluation, to avoid unreasonable load on the DNS. If this limit is exceeded, the implementation MUST return ‘permerror’.” The other terms, “the ‘all’, ‘ip4’, and ‘ip6’ mechanisms, and the ‘exp’ modifier,” cost nothing.

Three details make the count harder than it looks. First, it covers the whole evaluation, so **the terms inside an included record count too**, and an include that contains three more includes costs four lookups, not one. Second, there is a separate limit for lookups that come back empty: “SPF implementations SHOULD limit ‘void lookups’ to two,” and going over it also produces permerror. Third, the error does not degrade gracefully. **A record that needs eleven lookups does not pass the first ten entries and skip the rest; it returns permerror**, and DMARC counts nothing but a pass.

My record uses two of the ten, because the server’s own addresses are `ip4` and `ip6` entries, which are free, and neither provider’s record includes anything further. The count is easy to reproduce by hand, and the address totals in the table above are the sums of these ranges:

```
$ dig +short TXT _spf.mx.cloudflare.net @1.1.1.1
"v=spf1 ip4:104.30.0.0/19 ip6:2405:8100:c000::/38 ~all"
$ dig +short TXT spf.brevo.com @1.1.1.1
"v=spf1 ip4:185.41.28.0/22 ip4:94.143.16.0/21 ip4:185.24.144.0/22 ip4:153.92.224.0/19 ip4:213.32.128.0/18 ip4:185.107.232.0/22 ip4:77.32.128.0/18 ip4:77.32.192.0/19 ip4:212.146.192.0/18 ip4:172.246.0.0/18 -all"
```

### Mechanisms that cost a lookup and authorize the wrong machines

The RFC says the `a` mechanism “also matches AAAA records,” which sounds like an answer to the IPv6 problem above. On a site behind a CDN it is not. On this domain, the A and AAAA records point to Cloudflare’s proxy, not to my server, so **a bare `a` would authorize Cloudflare’s web edge to send mail as me**. The MX records point to Cloudflare’s inbound routing hosts, and the addresses they resolve to today are not even inside the range Cloudflare publishes for its outbound mail, so a bare `mx` would authorize machines that receive my mail and, by Cloudflare’s own list, do not send any. Each would cost a lookup to authorize the wrong machines. The `ptr` mechanism is worse, and the RFC says so plainly: “This mechanism SHOULD NOT be published.”

### One record, and only one

The last limit is the easiest one to break by accident. “A domain name MUST NOT have multiple records that would cause an authorization check to select more than one record,” says RFC 7208, and when a receiver finds more than one, “check_host() produces the ‘permerror’ result.” It usually happens when a new provider’s setup asks for a TXT record and someone adds a second SPF record instead of merging the new entry into the first. When the two addresses went into my record on September 9, **the change was an edit of the existing record**, and I checked afterwards that public resolvers returned exactly one record beginning with `v=spf1`.

## ~all (softfail) or -all (hardfail) when DMARC is at p=reject

The last term of the record is a choice. RFC 7208 calls a softfail “a weak statement by the publishing ADMD that the host is probably not authorized,” and tells receivers that they “SHOULD NOT reject the message based solely on this result, but MAY subject the message to closer scrutiny than normal.” What a receiver does with a fail is up to its own policy, and that is where the case for `~all` comes from. RFC 9989 warns that some receivers check SPF early in the SMTP conversation, so “an SPF hard fail (‘-’) prefix on a sender’s SPF mechanism, such as ‘-all’, could cause a message to be rejected early in the SMTP transaction, before any DMARC processing takes place.” It goes on: “messages that might otherwise pass DMARC due to an aligned DKIM-Authenticated Identifier … could be rejected solely due to an SPF fail. Moreover, messages rejected early in the SMTP transaction will never appear in aggregate DMARC reports.”

That last sentence decided it for me. With `~all`, a message from an address my record does not list still reaches the point where DMARC looks at both SPF and DKIM, and with my policy at `p=reject`, a message that fails both is rejected there, where the rejection shows up in the reports I receive. With `-all`, some of those rejections would happen before the receiver learned which domain the message claimed to come from, and **they would never reach the reports**. It is the same kind of blind spot I found when [my server’s access log could not see what the CDN edge answered](https://visibleorigin.com/access-log-cannot-see-the-edge/): the failures you most need to see are the ones that never arrive. For a domain that sends no mail at all, the reasoning reverses, and `v=spf1 -all`, as this site had at launch, is the clearest statement there is.

## How to check an SPF record yourself

Everything above can be repeated on any domain, and most of it needs nothing but a terminal. This is the order I follow.

### 1. Fetch the record from a public resolver

Ask a public resolver rather than the one your machine uses by default, which may hand you a cached or local answer. On Linux or macOS, `dig +short TXT example.com @1.1.1.1`; on Windows, `nslookup -type=txt example.com 1.1.1.1`. Then count the answers that begin with `v=spf1`. **There must be exactly one.**

### 2. Expand every include and count

Resolve each include, then each include inside it, and count every `include`, `a`, `mx`, `ptr`, `exists` and `redirect` on the way, writing down what each entry authorizes. If the total is close to ten, or any lookup comes back empty, fix that first: nothing else matters if the record cannot be evaluated.

### 3. Find whose domain is in each envelope

List the services that send mail as your domain: your own server, your email provider, your newsletter tool, your contact forms. For each one, open a real message it sent and read the headers. In Gmail, “Show original” displays the `Return-Path` and an `Authentication-Results` line whose `smtp.mailfrom=` names the domain SPF was checked against. **If that domain is not yours, your SPF record played no part in that message**, and the include you added for that provider is not what got it delivered.

### 4. On your own server, check both address families

If a server you run sends mail directly, find out which address families it uses. On Postfix, `postconf -h inet_protocols smtp_address_preference` answers in one line, and `ip -6 addr show scope global` shows whether the machine has a global IPv6 address at all. If both families are in play, the record needs an `ip4` and an `ip6` entry, and each address needs reverse DNS that names a hostname, which in turn points back to that address.

### 5. Test every path, and know which path each test took

Send a message along each path to a verifier that replies with a report; the free address `check-auth@verifier.port25.com` is the one I used. Then look in your mail log for the address each delivery actually used, and remember that **a pass is a statement about that delivery, not about the record**. If you would rather have this done from the outside, it is the same order I follow in a [review of a domain’s email routing and deliverability](https://visibleorigin.com/services/#6-business-email-routing-and-deliverability), and the first three steps need nothing but public DNS and a message you already received.

## SPF record: frequently asked questions

### What is an SPF record?

It is a TXT record at your domain, beginning with `v=spf1`, that lists the servers allowed to use your domain as the envelope sender (the MAIL FROM) of an email. A receiving server compares the address that connected to it with that list and gets a result such as pass, softfail or fail. A minimal example: `v=spf1 ip4:192.0.2.10 include:_spf.example.net ~all`, meaning one address, one provider, and softfail for everyone else.

### Can a domain have two SPF records?

No. RFC 7208 says a domain “MUST NOT have multiple records that would cause an authorization check to select more than one record,” and a receiver that finds two returns permerror, so neither counts. When a new provider asks for SPF, edit your existing record and add its include there. Other TXT records, such as verification codes, are fine, because only the ones beginning with `v=spf1` are read.

### What is the difference between ~all and -all?

`~all` ends the record with softfail, “probably not authorized”; `-all` ends it with fail, “not authorized.” Receivers are told not to reject on a softfail alone. With `-all`, some receivers reject during the SMTP conversation, before DMARC runs, which RFC 9989 warns can reject mail that a valid DKIM signature would have passed and keeps those rejections out of DMARC reports.

### What happens if an SPF record needs more than 10 DNS lookups?

The check returns permerror, and DMARC treats that as SPF not passing. The count includes every `include`, `a`, `mx`, `ptr`, `exists` and `redirect`, inside included records too, while `ip4`, `ip6` and `all` are free. The usual fixes are to drop includes for providers that put their own domain in the envelope and to replace `a` and `mx` with explicit addresses.

### Does SPF check the From address I see in my inbox?

No. It checks the envelope sender, the MAIL FROM, which after delivery usually appears as the `Return-Path` header and often names a different domain. DMARC is what connects SPF to the visible From, by requiring the two domains to align. In the words of RFC 9989: “DMARC relies solely on SPF validation of the MAIL FROM identity.”

### How do I check an SPF record?

Fetch it from a public resolver with `dig +short TXT example.com @1.1.1.1` or `nslookup -type=txt example.com 1.1.1.1`, confirm that exactly one answer begins with `v=spf1`, and resolve each include to count the lookups. Then open a real message and read its `Authentication-Results` header: the `spf=` result and the `smtp.mailfrom=` domain show whether your record was the one checked at all.

## The general shape of this

**An SPF record is a permission list**, and it is easy to read it as a description of your mail instead. Mine was valid in all three versions, and each was out of step with what the mail actually did: the first forbade mail the site was about to send, the second left out a server that was already sending, and the current one still carries an include for mail that, by the provider’s own account, is checked against the provider’s record. No checker that only parses the record would have shown any of it. Putting each entry next to the path it is supposed to cover did.

Every command in this note runs against this domain and this server, and the numbers will drift as providers change their ranges. The method will not: read the record, expand it, find whose domain is in each envelope, and test each path knowing which one you tested.

## Sources

- [RFC 7208, Sender Policy Framework (SPF)](https://www.rfc-editor.org/rfc/rfc7208.html): the definition, one record per domain, size, evaluation order, the lookup limits, and the include, a, ptr, ip4 and ip6 mechanisms.
- [RFC 9989, DMARC](https://www.rfc-editor.org/rfc/rfc9989.html): SPF counts only for the MAIL FROM identity (section 4.4.2), and the warnings about broad records and `-all` (section 7.1).
- [Google, Email sender guidelines](https://support.google.com/mail/answer/81126?hl=en): authentication, forward and reverse DNS, the IPv6 rejection message and third-party senders.
- [Postfix, postconf(5), smtp_address_preference](https://www.postfix.org/postconf.5.html#smtp_address_preference): which address family Postfix tries first, and how to deliver over IPv4 only.
- [Cloudflare, Email Service postmaster reference](https://developers.cloudflare.com/email-service/reference/postmaster/): sender rewriting for forwarded mail, outbound prefixes and the SPF breakdown.
- [Cloudflare, Email Service domain configuration](https://developers.cloudflare.com/email-service/configuration/domains/): the routing records on the root domain and the stated purpose of the SPF record.
- [Brevo Help Center, Authenticate your domain with Brevo](https://help.brevo.com/hc/en-us/articles/12163873383186-Authenticate-your-domain-with-Brevo-Brevo-code-DKIM-DMARC): SPF is not required to authenticate a domain.
- [Brevo Help Center, Domain setup for better email deliverability](https://help.brevo.com/hc/en-us/articles/35852083084178-Domain-setup-for-better-email-deliverability): without a branded subdomain, SPF and the return-path stay on Brevo’s infrastructure.
- [RFC 5737](https://www.rfc-editor.org/rfc/rfc5737.html) and [RFC 3849](https://www.rfc-editor.org/rfc/rfc3849.html): the addresses reserved for documentation, used here in place of the server’s.
- My own measurements on October 22, 2026, against 1.1.1.1 and on this server: the record and its includes, the lookup and address counts, the Postfix settings, the mail log since September 20, the reverse DNS of both addresses and check_host() for each version of the record. Earlier results: the port25 reports of September 9 and the Gmail headers of October 4, 2026.
