Picture it: Friday, 4:55 p.m. Marketing just signed up for a shiny new newsletter tool, and the onboarding wizard says, cheerfully, "Add this to your SPF record." Someone opens the DNS panel, sees there is already a TXT record starting with v=spf1, shrugs, and adds a second one. Ship it. Weekend.

Monday morning, password resets are landing in spam, invoices are bouncing, and the support inbox reads like a hostage negotiation. Nothing "changed", except that your domain now has two SPF records, which is the same as having none.

SPF record builders and generators are getting a lot of searches right now, and that is fair: the syntax is short, but it is unforgiving. This post walks through building an SPF record by hand, the two limits that quietly break it, and a small Node script to count your DNS lookups before a receiver does it for you.

What an SPF record actually is

SPF (Sender Policy Framework, RFC 7208) is a single DNS TXT record on your domain that lists which servers may send mail for it. A receiving server checks the IP that connected to it against that list, and DMARC then uses the result (if the domains align) to decide whether to trust the message.

A record is a version tag followed by terms, evaluated left to right, first match wins:

Read it like a bouncer's guest list: "the server at 192.0.2.10 is fine, anyone the newsletter tool vouches for is fine, everyone else gets a raised eyebrow."

The building blocks

Mechanisms say who matches:

Mechanism What it matches Costs a DNS lookup?
ip4: / ip6: An address or range you write down No
a The A/AAAA records of a domain Yes
mx The mail servers of a domain Yes
include: Whatever another domain's SPF record allows Yes, plus everything inside it
exists: Any domain that resolves (used for macros) Yes
ptr Reverse DNS. The RFC says it SHOULD NOT be published Yes
all Everything left over No

Qualifiers go in front and say what a match means: + pass (the default), - fail, ~ softfail, ? neutral.

Modifiers like redirect= hand the whole decision to another domain's record (and cost a lookup too).

Build it step by step

1. Inventory every sender. Not just your mailbox provider. The CRM, the help desk, the billing system, the newsletter tool, the app server, that one WordPress plugin. If it sends as yourdomain.com, it goes on the list. This is the step everybody skips and then debugs for a week.

2. Write one record, not one per tool. RFC 7208 is blunt: a domain must not have multiple SPF records, and if it does, receivers return a permanent error. That Friday-afternoon second record did not add a sender. It broke SPF for every sender. Merge everything into one line:

v=spf1 ip4:192.0.2.10 ip6:2001:db8::/32 include:spf.helpdesk.example include:spf.newsletter-tool.example ~all 

3. Prefer ip4/ip6 for servers you control. They are free. No lookups, no surprises when someone else edits their DNS.

4. End with ~all while you are still discovering senders. Once DMARC is in place, DMARC decides what happens to failing mail anyway, and ~all is the safer start. Move to -all when you are sure the inventory is complete. Never use +all. It means "anyone on the internet may send as me," which is less a policy and more an open invitation.

5. Publish it on the root (or on the exact subdomain you send from), as a TXT record. If your registrar's UI confuses you, here is a walkthrough for adding DNS records in Cloudflare, GoDaddy and Namecheap.

The two limits that break "valid" records

The 10-lookup limit

Every include, a, mx, ptr, exists and redirect costs one DNS lookup, and the count covers the whole tree: every record your includes pull in, and every record those pull in. Per RFC 7208, past 10 the receiver must return permerror, and SPF fails for all of your mail, not just the eleventh sender.

The sneaky part: you do not control the tree. A record with four includes can need eleven lookups because one provider nests five more inside theirs. Providers change their records without telling you, which is how an SPF record that passed in March breaks in November with zero edits on your side.

Void lookups

A "void lookup" is a lookup that comes back with nothing: no records, or a domain that does not exist. The RFC says implementations should limit these to two. Old includes for tools you cancelled years ago are the usual culprits. Delete them. They cost lookups and they can tip you into an error on their own.

TXT strings also max out at 255 characters each. A long record can be split into multiple quoted strings in the same TXT record, and receivers glue them back together, but that is a sign the record is doing too much.

Count your lookups before a receiver does

Here is a small Node script (Node 18+, ES modules) that follows include and redirect recursively and counts lookup-costing terms:

import { resolveTxt } from 'node:dns/promises'; const COSTS_LOOKUP = /^[+~?-]?(include:|a$|a:|a\/|mx$|mx:|mx\/|ptr|exists:|redirect=)/i; async function spfRecord(domain) { const records = (await resolveTxt(domain)).map(parts => parts.join('')); const spf = records.filter(r => r.toLowerCase().startsWith('v=spf1')); if (spf.length !== 1) throw new Error(`${domain}: expected 1 SPF record, found ${spf.length}`); return spf[0]; } async function countLookups(domain, path = new Set()) { if (path.has(domain)) throw new Error(`include loop at ${domain}`); path.add(domain); let count = 0; for (const term of (await spfRecord(domain)).split(/\s+/).slice(1)) { if (!COSTS_LOOKUP.test(term)) continue; count++; const target = term.match(/^[+~?-]?(?:include:|redirect=)(.+)$/i); if (target) count += await countLookups(target[1], new Set(path)); } return count; } const domain = process.argv[2]; const total = await countLookups(domain); console.log(`${domain}: ${total} lookups ${total > 10 ? '(over the limit: permerror)' : '(within the limit)'}`); 

Run it with node spf-count.mjs yourdomain.com. It is deliberately simple: it does not count the extra address lookups an mx triggers, it ignores macros, and it does not track void lookups. For the full picture, paste your domain into a free SPF record checker that counts lookups the way receivers do: it walks every nested include, flags duplicate records, void lookups, deprecated ptr and include loops, and explains each mechanism in plain English.

When you are over 10

  • Remove dead includes first. Cancelled tools, old ESPs, the trial you forgot. This is the cheapest fix and usually enough.
  • Swap a and mx for ip4/ip6 where you own the servers and the addresses are stable.
  • Move a sender to a subdomain. news.yourdomain.com can have its own SPF record and its own lookup budget.
  • Be careful with flattening. Replacing includes with the provider's current IPs works today and goes stale the day they change addresses, and then mail fails SPF with no warning. If you flatten, re-check it on a schedule. DNS drifts quietly; here is how to catch DNS drift on sending domains before your customers do.

Then check the rest of the chain

SPF alone does not get you to the inbox. Gmail, Yahoo and Microsoft look at SPF, DKIM and DMARC together, and SPF passing while DMARC fails is a classic alignment trap. Once your SPF record is clean, run the domain through a DMARC record checker to make sure the pieces line up.

And the next time an onboarding wizard says "add this to your SPF record," read it as "merge this into your SPF record." Your Monday self will thank you.

I work on Email Digit, an email platform that publishes and maintains SPF, DKIM and DMARC for the domains that send through it. This post was drafted with AI assistance and checked against RFC 7208.