spf

View script Copied!

Recursively resolve and inspect SPF DNS records. spf walks the full include: tree for a domain and answers the common inspection questions: what record is published (show), is this IP authorized (find), what are all the authorized addresses (flatten), is the record healthy (check), what does the full delegation tree look like (tree), is a specific mechanism token present anywhere in the tree (has), and what does the raw resolver output look like for piping into other tools (ir).

SPF authorization is often buried three or four levels of include: directives deep. A single TXT lookup rarely gives you the full picture -- spf follows every include recursively, counts DNS lookups against the RFC 7208 limit, and surfaces problems that would cause silent delivery failures in production.

Quick start

Check whether a specific IP is authorized to send email for a domain:

$ spf find mailchimp.com 205.201.128.1
205.201.128.1 is covered by ip4:205.201.128.0/20 in mailchimp.com (qualifier: +)

Exit 0 means the IP is authorized; exit 4 means it was not found.

The simplest question is "what SPF record does this domain actually publish?" show answers it: it does a single TXT lookup, reassembles the record, strips the quotes, and prints it on one line.

$ spf show github.com
v=spf1 ip4:192.30.252.0/22 include:spf.protection.outlook.com include:_netblocks.google.com ... ~all

A bare spf <domain> with no verb is shorthand for spf show <domain> -- the common case is the shortest to type:

$ spf github.com
v=spf1 ip4:192.30.252.0/22 include:spf.protection.outlook.com include:_netblocks.google.com ... ~all

show prints the record as published -- it does not follow include: directives. To expand the delegation tree, use tree; to enumerate every authorized address, use flatten.

A long SPF record is published as multiple 255-byte "character-strings" within a single TXT record; dig shows these as adjacent quoted tokens. show joins them back into one record. Pass -v to note when this happened:

$ spf show _spf.google.com -v
[INF][spf] published as 2 character-strings
v=spf1 ip4:74.125.0.0/16 ip4:209.85.128.0/17 ip6:2001:4860:4864::/56 ... ~all

If a domain publishes more than one v=spf1 record -- an RFC 7208 permerror -- show prints the first and warns about the rest on stderr. When no SPF record exists at all, show exits 1:

$ spf show example.org
[ERR][spf] No SPF record found for example.org

Unlike the other verbs, show takes a domain only: it fetches what is published, so a raw v=spf1 … record or - stdin has nothing to look up. To vet a record you already have in hand, use check.

Common examples

Check if a sending IP is authorized (find):

$ spf find github.com 192.30.252.1
192.30.252.1 is covered by ip4:192.30.252.0/22 in github.com (qualifier: +)

By default, spf prints only the verdict on stdout. Pass -v/--verbose to add the resolution trace on stderr:

$ spf find google.com 1.2.3.4 -v
[INF][spf] google.com: v=spf1 include:_spf.google.com ~all
[INF][spf] _spf.google.com: v=spf1 ip4:74.125.0.0/16 ip4:209.85.128.0/17 ip6:2001:4860:4864::/56 ip6:2404:6800:4864::/56 ip6:2607:f8b0:4864::/56 ip6:2800:3f0:4864::/56 ip6:2a00:1450:4864::/56 ip6:2c0f:fb50:4864::/56 ~all
1.2.3.4 not found in google.com's SPF record

[INF] lines show each record as it is fetched so you can trace the delegation path. The last stdout line is the verdict.

Check whether a host is explicitly listed as an a: sender (find a:<host>):

A common CI check: is your mail host still authorized by name (self-healing on renumber), or has it been flattened into an anonymous CIDR (fragile)?

$ spf find mailsenders.netsuite.com a:outboundips.netsuite.com
outboundips.netsuite.com matches a:outboundips.netsuite.com in mailsenders.netsuite.com (qualifier: +)

Exit 0 -- the host is present literally. If the host renumbers, the a: directive picks up the new address automatically.

Now imagine a record that was flattened and the host's IP landed in a static range instead:

$ spf find 'v=spf1 ip4:172.253.122.0/24 -all' a:smtp.gmail.com
a:smtp.gmail.com is NOT present literally, but its address 172.253.122.109 is covered by ip4:172.253.122.0/24 in <record> -- fragile: a flattened range does not self-heal if the host renumbers

Exit 5 -- the host resolves to an IP inside a CIDR, but the a: directive itself is missing. Email will flow today but break silently if the host renumbers.

$ spf find mailsenders.netsuite.com a:mail.example.com
a:mail.example.com not found in mailsenders.netsuite.com's SPF record

Exit 4 -- the directive is absent entirely.

In a CI script, branch on $?:

spf find "$domain" "a:$host" -q
case $? in
  0) echo "OK: a:$host present literally" ;;
  5) echo "WARN: a:$host covered by a CIDR range -- fragile" ;;
  4) echo "FAIL: a:$host not found" ;;
esac

Dump all authorized IP ranges (flatten):

$ spf flatten github.com
[WRN][spf] cannot statically evaluate exists (1 occurrence(s))
37.188.97.188/32
40.92.0.0/15
40.107.0.0/16
50.31.32.0/19
...

One CIDR per line on stdout. exists: mechanisms that reference a query-IP macro (%{i}) cannot be statically resolved and are reported as a warning on stderr.

Run an SPF health check (check):

$ spf check github.com
DNS lookups: 10/10
Void lookups: 0/2
Published SPF records: 1
OK: no problems found

check counts DNS lookups, void lookups, and flags problems like +all, multiple all mechanisms in the checked record, or ptr usage. Exit 0 when clean; exit 4 when problems are found.

Print the full delegation tree (tree):

$ spf tree 'v=spf1 ip4:1.2.3.0/24 include:_spf.google.com ~all'
  ip4:1.2.3.0/24
  include:_spf.google.com
      ip4:74.125.0.0/16
      ip4:209.85.128.0/17
      ip6:2001:4860:4864::/56
      ip6:2404:6800:4864::/56
      ip6:2607:f8b0:4864::/56
      ip6:2800:3f0:4864::/56
      ip6:2a00:1450:4864::/56
      ip6:2c0f:fb50:4864::/56
      ~all
  ~all

Mechanisms are indented by depth (2 spaces at the root, +4 per include level). include: lines mark delegation boundaries; the child mechanisms are indented beneath them.

Escape hatch: raw resolver IR (ir)

Every spf subcommand is built on a single resolver engine that emits a frozen six-column tab-delimited IR. The ir verb exposes that stream directly so you can pipe it into awk, grep, or any other tool without going through a higher-level command.

$ spf ir google.com
0	google.com	+	include	_spf.google.com	1
1	_spf.google.com	+	ip4	74.125.0.0/16	0
1	_spf.google.com	+	ip4	209.85.128.0/17	0
1	_spf.google.com	+	ip6	2001:4860:4864::/56	0
1	_spf.google.com	+	ip6	2404:6800:4864::/56	0
1	_spf.google.com	+	ip6	2607:f8b0:4864::/56	0
1	_spf.google.com	+	ip6	2800:3f0:4864::/56	0
1	_spf.google.com	+	ip6	2a00:1450:4864::/56	0
1	_spf.google.com	+	ip6	2c0f:fb50:4864::/56	0
1	_spf.google.com	~	all		0
0	google.com	~	all		0

Column layout (stable contract)

The six columns are separated by a single tab (\t). This layout is a stable contract -- the column positions will not change.

Column Name Description
1 depth Include nesting depth (0 = root record)
2 source_domain Domain that owns this mechanism
3 qualifier SPF qualifier (+, -, ~, ?)
4 mechanism Mechanism keyword (ip4, ip6, include, a, mx, all, redirect, exists, ptr, void)
5 value Mechanism value (CIDR, target domain, etc.; empty for bare all and ptr)
6 lookup_cost DNS lookup cost this row contributes (1 for mechanisms that require a DNS lookup, 0 otherwise)

Use -F'\t' in awk to split on tab. For example, to filter only include rows:

spf ir google.com | awk -F'\t' '$4=="include"'
0	google.com	+	include	_spf.google.com	1

Exit 0 when the record resolves; exit 1 when no SPF record is found.

Token search (has)

has answers one question: is a specific mechanism token present anywhere in the recursively-expanded SPF tree, and if so, where? It is a pure lexical scan -- no host resolution, no IP comparison. The token must match exactly: mechanism type, separator, and value must all be identical.

$ spf has google.com include:_spf.google.com
PRESENT: include:_spf.google.com
  in google.com (depth 0, qualifier: +)

Exit 0. The output names the mechanism and value, then lists every record that contains it with the owning domain, include depth, and qualifier.

When the token is not in the tree at any depth, has reports the absence and exits 4:

$ spf has google.com include:nonexistent.example
ABSENT: include:nonexistent.example not found in google.com's SPF tree

Exit 4. No record in the recursively-expanded tree contains the exact token.

Why has instead of dig txt | grep

Three things grep on a single DNS lookup cannot do:

  1. Recurses the include tree. dig TXT example.com returns one record. An include: two levels deep is invisible to grep; has follows every delegation.

  2. Exact token match, not substring match. grep include:site.com matches include:site.com.attacker.example -- a false positive that conceals a domain-takeover risk. has include:site.com requires the value to be exactly site.com with nothing following.

  3. Reports the qualifier. A mechanism can appear with a - (fail) qualifier. has shows qualifier: - in the provenance line, so you can see at a glance whether the match authorizes or rejects.

has vs find for a: directives

For IP-authorization questions involving a hostname, prefer find a:<host>. It resolves the host, checks the full CIDR coverage, and returns three states: present literally (exit 0), covered by a CIDR range but not named explicitly (exit 5, fragile), or absent (exit 4). has a:<host> only answers whether the literal a:<host> token appears in the tree -- it will not catch a flattened range that happens to cover the host's current address.

Cross-resolver comparison

Use -s to query a specific DNS server. Running flatten against two resolvers and diffing the output reveals split-horizon or geo-aware SPF -- common with providers that serve different IP ranges to different resolvers:

spf flatten google.com -s 8.8.8.8  > /tmp/spf-google.txt
spf flatten google.com -s 1.1.1.1  > /tmp/spf-cf.txt
diff /tmp/spf-google.txt /tmp/spf-cf.txt

An empty diff is the normal, healthy result -- both resolvers see the same IP ranges. A non-empty diff flags resolver-dependent SPF: one or more included domains returns different IP ranges depending on the resolver used. A mail server querying through a different resolver than your test machine may authorize a different set of IPs -- this is the root cause of "works for me but fails for some recipients" SPF failures.

Salesforce exists: macros

Salesforce uses exists:%{i}._spf.mta.salesforce.com instead of a static CIDR list. The mechanism is evaluated by doing an A lookup against a domain that encodes the query IP -- only registered Salesforce MTA IPs resolve.

find evaluates the macro: it expands %{i} to the query IP, performs the A lookup, and reports a match if the lookup resolves. The following uses a raw record with exists:%{d} anchored to google.com (so the A lookup hits google.com, which resolves -- a reliable demonstration of a positive match):

$ spf find 'v=spf1 exists:%{d} -all' 1.2.3.4 -d google.com
1.2.3.4 matches exists:%{d} in google.com (qualifier: +)

Note the qualifier is +: an exists: mechanism without an explicit qualifier sign defaults to pass, regardless of what the trailing all mechanism says.

flatten cannot expand exists: without a query IP and emits a warning instead:

$ spf flatten _spf.salesforce.com
[WRN][spf] cannot statically evaluate exists (1 occurrence(s))

No IP ranges are printed for the Salesforce mechanism because there are none to enumerate. Sender macros (%{s}, %{l}, %{o}, %{h}, %{p}) are likewise unevaluable in all modes and are noted but not matched.

Raw-record input

Pass a literal SPF record string instead of a domain name to vet a record before publishing:

$ spf check 'v=spf1 ip4:192.0.2.0/24 ~all'
DNS lookups: 0/10
Void lookups: 0/2
Record count: N/A (raw-record input)
OK: no problems found

spf detects a raw record by the leading v=spf1 prefix. Pipe via - for stdin:

$ echo 'v=spf1 ip4:10.0.0.0/8 ~all' | spf flatten -
10.0.0.0/8

Stdin also accepts raw dig +short TXT output directly -- the same reassembly the domain path applies (joining 255-byte character-strings, stripping quotes, selecting the v=spf1 record among other TXT records) runs on stdin too. So you can resolve with a dig invocation spf does not expose (a specific transport, +trace, DNSSEC) and pipe the result straight in:

$ dig +short TXT _spf.google.com | spf flatten -
74.125.0.0/16
209.85.128.0/17
...

A record already on one clean line passes through unchanged, so both forms work.

If the record contains %{d} macros, anchor them with -d/--domain:

spf check 'v=spf1 exists:%{i}._spf.mta.example.com -all' -d example.com

Flattening staleness

flatten captures the provider's current IP ranges at the moment you run it. If you publish the flattened result as a static TXT record, it will drift as the provider adds or renumbers servers -- your record will silently fail to authorize legitimate senders.

This is the SPF equivalent of hardcoding a CNAME target's IP instead of using the CNAME itself: correct today, broken on the day the provider renumbers. The safe deployment pattern is to flatten for auditing, use check to confirm the live record stays healthy, and let the provider's include: chain do the actual authorization in production. Also note a flattened record must fit within a single TXT resource record (510 bytes of SPF content after the tag).


Reference

All options

Flag Description
-s, --server ip DNS server to query (default: 8.8.8.8)
-d, --domain dom Anchor %{d} macros when input is a raw record
-t, --tcp Force TCP instead of UDP for dig queries
-q, --quiet Errors only -- suppress warnings and the resolution trace
-v, --verbose Show the resolution trace ([INF] lines) on stderr
-h, --help Show the top-level help; spf <verb> -h (or spf -h <verb>) shows per-verb detail
-r, --record (flatten only) Emit the result as a quoted TXT record string instead of one CIDR per line

Verbosity levels (last flag wins when both are given):

Level Flag Output
Default (none) Verdict on stdout; [WRN] advisories on stderr
Quiet -q, --quiet Verdict on stdout (unchanged); stderr silenced -- no warnings, no trace
Verbose -v, --verbose Adds [INF] resolution trace to stderr

Exit codes

Code Meaning
0 Success: record printed (show), IP covered (find), output emitted (flatten), record clean (check), tree rendered (tree), token found (has)
1 Runtime failure: no SPF record found, DNS error, or unexpected dig output
2 Usage error: missing argument, unknown flag, or bad flag value
3 dig is not installed
4 Negative predicate result: IP not found (find), problems detected (check), or token absent (has)
5 find a:<host>: host is absent as a literal directive but its resolved address is covered by a CIDR range (fragile)

Exit code 5 applies to find only. Exit code 4 means: IP not found (find), problems detected (check), or token absent (has). flatten and tree always exit 0 on success.

Dependencies

Limitations