How to check SPF record

Query the domain's TXT records and keep the one that starts with v=spf1: nslookup -type=TXT github.com 8.8.8.8. A domain publishes exactly one such record. Read the all mechanism at its end, because that qualifier is what a receiver applies to every sender the record did not list.

Why check this

Run this when the sending path changes: a new transactional provider, a new marketing tool, a move of the mail platform, or a DNS cutover. The failure it prevents is specific. Password reset mail starts leaving through a provider whose addresses are not in the record, the receiver applies the all qualifier instead, and the message is filed as spam or rejected while the application logs a successful send.

The check is a read, not a test send. It tells you which senders the domain claims, whether the record parses, and what happens to everything else. It does not tell you whether your application actually sends from one of those addresses, and it says nothing about the From: header your users see. Those belong to How to check DMARC alignment.

Prerequisites

const { Resolver } = require('dns');
const r = new Resolver();
r.setServers(['8.8.8.8']);          // name the resolver: Node's default is not always usable

r.resolveTxt(process.argv[2], (err, recs) => {
  if (err) return console.log('lookup failed: ' + err.code);
  const spf = recs.filter(chunks => chunks.join('').startsWith('v=spf1'));
  console.log('SPF records      : ' + spf.length + (spf.length === 1 ? '' : '  <-- must be exactly 1'));
  if (spf.length !== 1) return;
  const rec = spf[0].join('');      // RFC 7208 section 3.3: concatenate, no separator
  console.log('TXT strings      : ' + spf[0].length + '  (lengths ' + spf[0].map(s => s.length).join(', ') + ')');
  console.log('joined length    : ' + rec.length);
  const terms = rec.split(/\s+/).slice(1);
  const all = terms.find(t => /^[-~?+]?all$/i.test(t));
  console.log('all mechanism    : ' + (all || 'MISSING, evaluation ends in neutral'));
  console.log('ptr mechanism    : ' + (terms.some(t => /^[-~?+]?ptr/i.test(t)) ? 'present, RFC 7208 section 5.5 says do not publish it' : 'none'));
  console.log('macros           : ' + (terms.filter(t => t.includes('%{')).join(' ') || 'none'));
  console.log('record           : ' + rec);
});

Steps

  1. Step 1.

    Ask a named resolver for the TXT records of the domain.

    nslookup -type=TXT github.com 8.8.8.8
    
    Non-authoritative answer:
    Server:  dns.google
    Address:  8.8.8.8
    
    github.com	text =
    
    "krisp-domain-verification=ZlyiK7XLhnaoUQb2hpak1PLY7dFkl1WE"
    github.com	text =
    
    "facebook-domain-verification=39xu4jzl7roi7x0n93ldkxjiaarx50"
    github.com	text =
    
    "google-site-verification=UTM-3akMgubp6tQtgEuAkYNYLyYAvpTnnSrDMWoDR3o"
    …24 TXT records in total

    SPF lives among the vendor verification strings. Nothing marks it apart except the v=spf1 prefix.

  2. Step 2.

    Filter the listing down to the SPF record.

    nslookup -type=TXT github.com 8.8.8.8 | grep -i spf1
    
    	"v=spf1 ip4:192.30.252.0/22 include:spf.protection.outlook.com include:_netblocks.google.com include:_netblocks2.google.com include:mail.zendesk.com include:_spf.salesforce.com include:servers.mcsv.net include:mktomail.com include:sendgrid.net ip4:62.253.2"

    Read the end of that line. It stops at ip4:62.253.2, which is not an address, and there is no all mechanism. The record is not broken; the filter cut it. Step 3 shows why.

  3. Step 3.

    Read the record through a resolver that joins the TXT strings.

    node spf-record.js github.com
    
    SPF records      : 1
    TXT strings      : 2  (lengths 255, 65)
    joined length    : 320
    all mechanism    : ~all
    ptr mechanism    : none
    macros           : none
    record           : v=spf1 ip4:192.30.252.0/22 include:spf.protection.outlook.com include:_netblocks.google.com include:_netblocks2.google.com include:mail.zendesk.com include:_spf.salesforce.com include:servers.mcsv.net include:mktomail.com include:sendgrid.net ip4:62.253.227.114 ip4:166.78.69.169 ip4:166.78.69.170 ip4:166.78.71.131 ~all

    The record is 320 characters, so DNS carries it as two strings of 255 and 65. The split falls inside an address: ip4:62.253.2 plus 27.114 is ip4:62.253.227.114.

  4. Step 4.

    Compare with a domain that sends no mail at all.

    node spf-record.js example.com
    
    SPF records      : 1
    TXT strings      : 1  (lengths 11)
    joined length    : 11
    all mechanism    : -all
    ptr mechanism    : none
    macros           : none
    record           : v=spf1 -all
  5. Step 5.

    Check whether the record depends on macros, which no static read can resolve.

    node spf-record.js mozilla.org
    
    SPF records      : 1
    TXT strings      : 1  (lengths 137)
    joined length    : 137
    all mechanism    : ~all
    ptr mechanism    : none
    macros           : include:%{i}._ip.%{h}._ehlo.%{d}._spf.vali.email
    record           : v=spf1 include:%{i}._ip.%{h}._ehlo.%{d}._spf.vali.email include:_spf.mozilla.com include:_spf.google.com include:spf.fundraiseup.com ~all

    %{i} is the connecting IP address and %{h} is the HELO name. The include target only exists at evaluation time, so the answer for one sender says nothing about another.

How to read the result

| What you see | What it means | What to do | | --- | --- | --- | | -all at the end | Everything not listed fails SPF | Confirm every sending system is listed before publishing this. | | ~all at the end | Everything not listed is a soft fail | Normal while a sending path is still being inventoried. | | ?all or +all | The record authorises nobody, or everybody | +all authorises the whole internet. Remove it. | | No all mechanism | Evaluation ends in neutral | Add one. A record without it decides nothing. | | SPF records : 2 | Every check returns permerror | Merge the two records into one. Both are ignored until you do. | | A %{ macro in a term | The target is built from the sender | Static reads cannot verify it. Test with a real send instead. |

Common mistakes

Sign: The record you read ends part way through an IP address and has no all mechanism.Cause: A TXT record is a list of strings of at most 255 characters each, and RFC 7208 section 3.3 says a receiver concatenates them with nothing in between. github.com's record is 320 characters, so it is published as two strings. A grep for v=spf1 returns the first string only, and the tail of the record, ~all included, never reaches you.
Sign: The SPF record lists the right provider and mail from that provider still fails SPF.Cause: SPF authenticates the envelope sender, the domain in MAIL FROM, not the From: header the reader sees. When a provider leaves its own bounce domain on the envelope, the receiver evaluates the provider's record and never looks at yours.
Sign: A migration leaves two v=spf1 records published for a few hours.Cause: RFC 7208 section 4.5 makes more than one matching record a permerror, and the evaluation stops there. Both records are discarded, including the one that was correct, so the domain is worse off than with a single old record.

What to check next

FAQ

How to check an SPF record for a domain?

Query TXT on the domain itself, never on a _spf prefix. _spf.example.com is a common include target, not the record a receiver looks up. The record that counts is the one on the bare domain in the envelope sender.

How to check an SPF record using nslookup?

nslookup -type=TXT example.com 8.8.8.8. Read the whole answer rather than a grepped line, because a record over 255 characters arrives as several quoted strings on separate lines and they belong together.

How to test if an SPF record is working?

A read tells you the record parses. Only a real message tells you it passes, because the result depends on the sending IP and the envelope domain. Send one to a mailbox you control and read its Authentication-Results header.

Does an SPF record stop spoofing?

On its own, no. SPF covers the envelope sender, which the recipient never sees. Stopping a forged From: header needs a DMARC policy, which is checked in How to check DMARC record.

Verified

Verified by Maks Vernynslookup Windows 10.0.22631node 22.23.2

Each output block is what the command above it printed on that date, on the host named in the step. Figures read from a live site move between runs. Compare the shape of the answer rather than the digits, and see the methodology for how a page is re-verified.

basic4 minpublished updated Maks Verny