<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Lisa</title>
    <description>The latest articles on DEV Community by Lisa (@linxia_echo_d827208a7fe99).</description>
    <link>https://dev.to/linxia_echo_d827208a7fe99</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4105650%2F098c31b9-51b2-4c05-bb5e-257a71e2a81d.png</url>
      <title>DEV Community: Lisa</title>
      <link>https://dev.to/linxia_echo_d827208a7fe99</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/linxia_echo_d827208a7fe99"/>
    <language>en</language>
    <item>
      <title>I scanned 500 domains to test my SPF checker. It found four bugs in the checker.</title>
      <dc:creator>Lisa</dc:creator>
      <pubDate>Wed, 02 Sep 2026 07:42:31 +0000</pubDate>
      <link>https://dev.to/linxia_echo_d827208a7fe99/i-scanned-500-domains-to-test-my-spf-checker-it-found-four-bugs-in-the-checker-1jmb</link>
      <guid>https://dev.to/linxia_echo_d827208a7fe99/i-scanned-500-domains-to-test-my-spf-checker-it-found-four-bugs-in-the-checker-1jmb</guid>
      <description>&lt;p&gt;SPF has a limit almost nobody hits on purpose and plenty of people hit by accident: a receiver is allowed to make &lt;strong&gt;ten DNS lookups&lt;/strong&gt; while evaluating your record, and if the chain needs an eleventh it returns &lt;code&gt;permerror&lt;/code&gt; and throws your entire SPF away. Not the eleventh mechanism — the whole record. Your carefully maintained list of authorised senders stops meaning anything, and nothing anywhere tells you.&lt;/p&gt;

&lt;p&gt;Counting those ten looks like a five-line function. I wrote it, wrote unit tests for it, and then ran it against the top 500 domains of the Majestic Million to see what the real-world distribution looked like.&lt;/p&gt;

&lt;p&gt;The distribution was a footnote. The interesting part was that the scan found four bugs in my counter, and none of my existing unit tests had caught any of them.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rule everyone gets wrong first
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;include:&lt;/code&gt; recurses, and &lt;strong&gt;the lookups inside the included record count toward your ten&lt;/strong&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight conf"&gt;&lt;code&gt;&lt;span class="n"&gt;example&lt;/span&gt;.&lt;span class="n"&gt;com&lt;/span&gt;    &lt;span class="n"&gt;v&lt;/span&gt;=&lt;span class="n"&gt;spf1&lt;/span&gt; &lt;span class="n"&gt;include&lt;/span&gt;:&lt;span class="err"&gt;_&lt;/span&gt;&lt;span class="n"&gt;spf&lt;/span&gt;.&lt;span class="n"&gt;vendor&lt;/span&gt;.&lt;span class="n"&gt;com&lt;/span&gt; -&lt;span class="n"&gt;all&lt;/span&gt;
&lt;span class="err"&gt;_&lt;/span&gt;&lt;span class="n"&gt;spf&lt;/span&gt;.&lt;span class="n"&gt;vendor&lt;/span&gt;.&lt;span class="n"&gt;com&lt;/span&gt;    &lt;span class="n"&gt;v&lt;/span&gt;=&lt;span class="n"&gt;spf1&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="n"&gt;mx&lt;/span&gt; &lt;span class="n"&gt;include&lt;/span&gt;:&lt;span class="n"&gt;relay&lt;/span&gt;.&lt;span class="n"&gt;vendor&lt;/span&gt;.&lt;span class="n"&gt;com&lt;/span&gt; -&lt;span class="n"&gt;all&lt;/span&gt;
&lt;span class="n"&gt;relay&lt;/span&gt;.&lt;span class="n"&gt;vendor&lt;/span&gt;.&lt;span class="n"&gt;com&lt;/span&gt;   &lt;span class="n"&gt;v&lt;/span&gt;=&lt;span class="n"&gt;spf1&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="n"&gt;mx&lt;/span&gt; -&lt;span class="n"&gt;all&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is not one lookup. It is &lt;code&gt;include&lt;/code&gt; (1) + &lt;code&gt;a&lt;/code&gt; &lt;code&gt;mx&lt;/code&gt; (2) + &lt;code&gt;include&lt;/code&gt; (1) + &lt;code&gt;a&lt;/code&gt; &lt;code&gt;mx&lt;/code&gt; (2) = &lt;strong&gt;six&lt;/strong&gt;. Counting only the top-level &lt;code&gt;include:&lt;/code&gt; mechanisms — which is the shape most naive implementations take — reports one and tells the user everything is fine.&lt;/p&gt;

&lt;p&gt;Which mechanisms cost a lookup is also less obvious than it should be. &lt;code&gt;include&lt;/code&gt;, &lt;code&gt;a&lt;/code&gt;, &lt;code&gt;mx&lt;/code&gt;, &lt;code&gt;ptr&lt;/code&gt;, &lt;code&gt;exists&lt;/code&gt; and the &lt;code&gt;redirect=&lt;/code&gt; modifier all cost one each. &lt;code&gt;ip4&lt;/code&gt;, &lt;code&gt;ip6&lt;/code&gt;, &lt;code&gt;all&lt;/code&gt; and the &lt;code&gt;exp=&lt;/code&gt; modifier cost nothing. So &lt;code&gt;redirect=&lt;/code&gt; counts but &lt;code&gt;exp=&lt;/code&gt; does not, and they are both modifiers with identical syntax.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug 1: redirect is free when &lt;code&gt;all&lt;/code&gt; is present
&lt;/h2&gt;

&lt;p&gt;RFC 7208 §6.1 says receivers must ignore &lt;code&gt;redirect=&lt;/code&gt; entirely if the record also contains an &lt;code&gt;all&lt;/code&gt; mechanism. Ignore means no query, which means no lookup.&lt;/p&gt;

&lt;p&gt;My code charged for it anyway. On one test record that turned a true count of 1 into a reported 7 — enough to fire a false &lt;code&gt;SPF_TOO_MANY_LOOKUPS&lt;/code&gt; on a domain that was completely fine.&lt;/p&gt;

&lt;p&gt;A false positive here is worse than a miss. Telling someone their working SPF is broken costs you their trust immediately.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug 2: bare &lt;code&gt;a&lt;/code&gt; and &lt;code&gt;mx&lt;/code&gt; resolve against the wrong domain
&lt;/h2&gt;

&lt;p&gt;Inside an &lt;code&gt;include:&lt;/code&gt;, a bare &lt;code&gt;a&lt;/code&gt; or &lt;code&gt;mx&lt;/code&gt; with no domain attached evaluates against the &lt;strong&gt;current&lt;/strong&gt; domain — the included one — not the domain you started the analysis from. RFC 7208 §5.3.&lt;/p&gt;

&lt;p&gt;My expander kept resolving against the original domain, so it queried names that had nothing to do with the record it was reading and mis-attributed the void lookups that resulted.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug 3: the uncertainty was pointed backwards
&lt;/h2&gt;

&lt;p&gt;This is the one I keep thinking about, because it is not a coding mistake. It is a reasoning mistake that produced correct-looking output.&lt;/p&gt;

&lt;p&gt;Expansions fail partway. DNS times out, a name disappears, a macro like &lt;code&gt;exists:%{i}._spf.mta.salesforce.com&lt;/code&gt; cannot be evaluated without a live sender. So sometimes you finish with an incomplete walk and a partial count.&lt;/p&gt;

&lt;p&gt;The instinct is to mark partial results as unreliable. But partial expansion has a direction: &lt;strong&gt;it can only ever undercount&lt;/strong&gt;. The part you did not reach can only add lookups, never remove them.&lt;/p&gt;

&lt;p&gt;So:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a partial count already &lt;strong&gt;over&lt;/strong&gt; 10 is &lt;em&gt;certain&lt;/em&gt; — the unexplored remainder can only push it further over;&lt;/li&gt;
&lt;li&gt;a partial count &lt;strong&gt;under&lt;/strong&gt; 10 proves nothing at all.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I had it exactly backwards. All four genuinely over-limit domains were flagged "needs review", while healthy-looking partial results were passed through as confirmed.&lt;/p&gt;

&lt;p&gt;The macro case falls out of the same reasoning. A macro only creates uncertainty in &lt;code&gt;include:&lt;/code&gt; and &lt;code&gt;redirect=&lt;/code&gt; — the two mechanisms that recurse, where not knowing the target means not knowing what is inside it. A macro in &lt;code&gt;exists:&lt;/code&gt; or &lt;code&gt;a:&lt;/code&gt; costs exactly one lookup whatever it expands to, so the count stays exact.&lt;/p&gt;

&lt;p&gt;That distinction was not academic. 122 of the 433 SPF publishers in the scan carry a macro, nearly all of them the Salesforce one above. Treating any macro as poisoning the count marked 29% of the dataset unreliable and flooded the review queue with 153 rows of noise.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug 4: the scan harness corrupted its own results
&lt;/h2&gt;

&lt;p&gt;The scanner is resumable and appends one JSONL row per attempt, so a domain scanned three times has three rows. Collapsing to one row per domain, I kept the most recent.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;who.int&lt;/code&gt; has the deepest chain in the top 500 — 17 lookups. It was measured correctly at 17 four separate times. Then a fifth attempt, run over a lossy link, reached only 10 and reported &lt;strong&gt;no problem at all&lt;/strong&gt;. That was the row the report used.&lt;/p&gt;

&lt;p&gt;The fix is to keep the &lt;em&gt;fullest&lt;/em&gt; expansion rather than the newest, which is only sound because of the one-way property from bug 3. A worse network gives you a smaller number, never a bigger one, so "most complete" is a safe ranking.&lt;/p&gt;

&lt;h2&gt;
  
  
  The domain that had SPF but appeared not to
&lt;/h2&gt;

&lt;p&gt;I wrote a second lookup counter in Python, straight from the RFC over &lt;code&gt;dig&lt;/code&gt;, deliberately not derived from the Go code, so that agreement between them would be evidence rather than a restatement. Fifteen domains, zero discrepancies in logic.&lt;/p&gt;

&lt;p&gt;Except one. &lt;code&gt;wisc.edu&lt;/code&gt;: my Go implementation said 3 lookups, the Python checker said &lt;strong&gt;no SPF record at all&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The checker was wrong, and the reason is worth carrying around. &lt;code&gt;wisc.edu&lt;/code&gt; publishes 22 TXT records. The response does not fit in a 512-byte UDP packet. A client that does not advertise EDNS0 and does not retry over TCP receives a truncated answer, finds no &lt;code&gt;v=spf1&lt;/code&gt; string in it, and concludes the domain publishes no SPF.&lt;/p&gt;

&lt;p&gt;That would be a &lt;code&gt;critical&lt;/code&gt; finding, a 25-point deduction, against a correctly configured university. Produced by a resolver default, not by anything about the domain.&lt;/p&gt;

&lt;p&gt;If you are writing anything that reads DNS and draws conclusions, make sure your resolver does EDNS0 and TCP fallback. The failure is silent and it looks exactly like a real finding.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the 500 domains actually showed
&lt;/h2&gt;

&lt;p&gt;The scan was supposed to prove that lookup overflow is common. It isn't:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;count&lt;/th&gt;
&lt;th&gt;share&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;publishes SPF&lt;/td&gt;
&lt;td&gt;433 / 500&lt;/td&gt;
&lt;td&gt;86.6%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;over the 10-lookup limit&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;4&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.9%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;permerror (multiple records / syntax)&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;0.7%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;+all&lt;/code&gt; / &lt;code&gt;?all&lt;/code&gt; — no real protection&lt;/td&gt;
&lt;td&gt;14&lt;/td&gt;
&lt;td&gt;3.2%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;no SPF record at all&lt;/td&gt;
&lt;td&gt;67&lt;/td&gt;
&lt;td&gt;13.4%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Nought point nine percent. The headline I had in mind — "X% of major domains have SPF that silently no longer works" — was not available at that number, and I dropped it.&lt;/p&gt;

&lt;p&gt;Two things the same data does support. &lt;strong&gt;21 domains sit at exactly 10&lt;/strong&gt;, one added vendor away from tipping over. And the breadth is worse than the depth: 13.4% publish nothing at all, 3.2% publish something that authorises the entire internet.&lt;/p&gt;

&lt;p&gt;Bear in mind this list is the most professionally managed tail of the web. Whatever the rate is among small business domains, it is not lower.&lt;/p&gt;

&lt;h2&gt;
  
  
  Latency is chain depth, and nothing else
&lt;/h2&gt;

&lt;p&gt;Once it was running as a service, the thing I most wanted to optimise turned out not to be optimisable.&lt;/p&gt;

&lt;p&gt;An SPF chain is &lt;strong&gt;serial by construction&lt;/strong&gt;. Each &lt;code&gt;include:&lt;/code&gt; has to be resolved before you know what the next one is. No amount of concurrency changes that, because the work has a dependency order.&lt;/p&gt;

&lt;p&gt;So response time is close to a pure function of chain depth. Measured on a 1 vCPU box with a local &lt;code&gt;unbound&lt;/code&gt;:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;domain&lt;/th&gt;
&lt;th&gt;lookups&lt;/th&gt;
&lt;th&gt;cold&lt;/th&gt;
&lt;th&gt;provider names prewarmed&lt;/th&gt;
&lt;th&gt;saved&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;shopify.com&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;303ms&lt;/td&gt;
&lt;td&gt;109ms&lt;/td&gt;
&lt;td&gt;64%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;stripe.com&lt;/td&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;td&gt;826ms&lt;/td&gt;
&lt;td&gt;83ms&lt;/td&gt;
&lt;td&gt;90%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;who.int&lt;/td&gt;
&lt;td&gt;17&lt;/td&gt;
&lt;td&gt;3274ms&lt;/td&gt;
&lt;td&gt;2032ms&lt;/td&gt;
&lt;td&gt;38%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Prewarming means a timer that keeps the common provider SPF targets — &lt;code&gt;_spf.google.com&lt;/code&gt;, &lt;code&gt;spf.protection.outlook.com&lt;/code&gt;, &lt;code&gt;sendgrid.net&lt;/code&gt; and about thirty others — resident in the local resolver cache.&lt;/p&gt;

&lt;p&gt;Stripe is the interesting row. Four of its seven lookups go through &lt;code&gt;include:spf1.stripe.com&lt;/code&gt;, Stripe's own name, which is obviously not on any prewarm list. I predicted a modest gain. It came out at 90%, because the &lt;em&gt;end&lt;/em&gt; of that chain is Amazon SES and Zendesk, which are. &lt;strong&gt;A self-hosted include is a hop, not a cost.&lt;/strong&gt; The recursion that takes time happens at the provider domains it eventually points to.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;who.int&lt;/code&gt; is the counterexample that keeps you honest: a good part of its 17 lookups are names only it uses, and nothing can warm those. It stays at two seconds.&lt;/p&gt;

&lt;p&gt;Which meant the flat "p95 under 500ms" target I had written down was wrong — not missed, wrong. It is comfortable for most domains and physically unreachable for deep ones, by any implementation. The honest thing to publish is the distribution, not an average that hides it.&lt;/p&gt;




&lt;p&gt;The checker is a Go service now, and the version with the four bugs fixed is on RapidAPI as &lt;a href="https://rapidapi.com/linxiaecho/api/email-verification-and-domain-health" rel="noopener noreferrer"&gt;Email Verification and Domain Health&lt;/a&gt; if you want to point it at a domain. But the part worth taking away is cheaper than that: if you maintain a domain that sends mail, expand your own SPF chain and count. You are allowed ten, 21 of the 500 I scanned are sitting on exactly ten, and the failure mode is silent.&lt;/p&gt;

</description>
      <category>go</category>
      <category>dns</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
