Back to blog
Threat Intel
Phishing Forensics

Anatomy of an SPF PermError: Our Postmortem on Silent Void Lookups

An `include` for a decommissioned service silently exceeded our SPF record's two-void-lookup limit, causing a catastrophic PermError we never saw coming.

MailSleuth Research
Email Security Team
July 30, 20267 min read
An editorial illustration of a heavy chain with one broken, glowing link, symbolizing how a single void lookup in an SPF

It started on a Monday with a trickle of high-priority tickets. A key partner’s finance team wasn’t receiving our invoices. Then a major client reported our status updates were going straight to their quarantine. Our own monitoring showed green across the board. Our DMARC aggregate reports showed 99%+ alignment. Our public-facing SPF check tools all returned a reassuring `pass`.

But they were wrong. Or rather, they were only telling a fraction of the story. Buried deep in the headers of the failed messages was the real culprit: `Authentication-Results: ... spf=permerror (domain of example.com does not designate <IP> as permitted sender) smtp.mailfrom=user@example.com`. A Permanent Error. Not a `fail`, not a `softfail`, but a specific DNS-related failure that most tools weren't configured to surface.

This is the story of how two DNS records that no longer existed brought down our deliverability, and why the most dangerous part of your SPF record is the part you don’t own.

From 'Deliverability Drop' to Root Cause

The initial triage was maddening. We sent test emails from our mail provider to external accounts, and the SPF results in the headers came back `pass`. We checked our record on every validator we could find. No issues. The 10-DNS-lookup limit wasn't being hit. No syntax errors were present. We were flying blind.

The breakthrough came when a senior engineer on the infrastructure team got his hands on the raw headers from a message that a partner’s mail gateway had rejected. Unlike Gmail or Microsoft 365, which can be lenient, this upstream gateway was a strict RFC purist. It saw a `PermError` and it acted accordingly.

Chasing the Ghost in the Includes

A `PermError` isn't a simple pass/fail judgment. It means the receiver's mail transfer agent (MTA) couldn't even finish evaluating the policy. It gave up. The common causes are syntax errors or exceeding the 10-lookup limit. Since we'd ruled those out, we had to look at the more obscure failure modes defined in RFC 7208.

The process was tedious. We manually walked our SPF record's `include` chain. Our record looked something like this: `v=spf1 include:_spf.google.com include:sendgrid.net include:vendorservice.com ip4:x.x.x.x -all`. The first two includes were fine. But then we hit `vendorservice.com`. This was a marketing analytics tool we'd decommissioned six months ago. The contract ended, the CNAMEs were removed, but nobody thought to purge it from the TXT record in DNS.

A quick `dig txt _spf.vendorservice.com` returned `status: NXDOMAIN`. A nonexistent domain. That’s one 'void lookup'. But one void lookup doesn’t trigger a `PermError`. One more had to be lurking. We dug deeper into the `include` chain of another, still-active service. Nested within their own SPF record was another `include` for a legacy provider they had acquired and retired. Bingo. `NXDOMAIN`. Two void lookups. Game over.

Unpacking RFC 7208 and the Void Lookup

To understand why this happened, you have to go straight to the source: RFC 7208, Section 4.6.4. A 'void lookup' is essentially a DNS query that comes up empty. This can happen in two ways: the DNS query returns a `NOERROR` response code with an empty answer section (meaning the domain exists, but no record of the requested type does), or it returns an `NXDOMAIN` response code (meaning the domain itself doesn't exist at all).

These aren't treated as a simple `fail`. From the resolver's perspective, it's an ambiguous state. It's not a definitive 'no,' it's a 'I can't find what you're looking for.' Imagine sending an intern to a library to find a book. A `fail` is finding the book and seeing 'ACCESS DENIED' stamped on the cover. A void lookup is the intern reporting that the shelf the book is supposed to be on doesn't exist.

If a DNS query for a name returns a response with an RCODE of NXDOMAIN or a response with an RCODE of NOERROR and no records of the requested type, that is a 'void lookup'. — RFC 7208, Section 4.6.4

The Two-Void-Lookup Rule: SPF's Hidden Fuse

Every admin who's managed a complex SPF record knows about the 10-DNS-lookup limit. It's the most common cause of `PermError`. But far fewer are aware of its sibling: the two-void-lookup limit.

RFC 7208 states that if the total number of void lookups generated during the processing of an SPF record exceeds two, the check must immediately terminate and return a `PermError`. This isn't optional; it's a `MUST` requirement for compliant SPF evaluators.

A Defense Against Denial of Service

Why does this limit exist? It's a protection mechanism against Denial of Service (DoS) attacks. Without it, an attacker could craft a malicious SPF record with a long list of `include` mechanisms pointing to nonexistent, slow-to-respond, or randomly generated domains. `include:a.garbage.com include:b.garbage.com` and so on.

Every MTA that receives an email from the attacker's domain would be forced to issue a series of futile DNS queries. These queries consume resolver resources, state table entries, and network bandwidth. Multiplied across thousands of receiving MTAs, this could launch a significant distributed DoS attack against authoritative DNS servers or open resolvers. The two-void-lookup limit acts as a circuit breaker, preventing your domain's SPF record from being weaponized.

In our case, we weren't being malicious. We were just guilty of poor hygiene. But the receiving MTA doesn't know the difference. It saw two failed DNS lookups and, to protect itself, threw the `PermError` flag, just as the specification dictates.

Manually Replicating the PermError

You can't trust all public SPF validators to catch this. Many only count total lookups or check syntax. To truly diagnose a void lookup issue, you need to get your hands dirty with `dig`.

Using `dig` to Find Ghosts

For every `include`, `a`, `mx`, and `exists` mechanism in your SPF chain, run a manual query. For an `include:sub.domain.com`, you'd run:

$ dig txt sub.domain.com

Watch the `status:` line in the response header. If you see `NXDOMAIN`, that's one void lookup. If you see `NOERROR` but the `ANSWER SECTION` is empty, that's also a void lookup. Now repeat this for every single dependency in your entire chain, including the mechanisms inside your included services' own SPF records. That's where the real danger lies.

Scripting the Audit

Doing this manually is brutal for a complex record. This is trivial to script. A simple shell script using `dig` and `grep` or a Python script with a library like `dnspython` can recursively walk the `include` chain. The logic is straightforward: parse the main SPF record, and for each mechanism that requires a DNS query, perform the lookup. If it's a void lookup, increment a counter. If it's an `include`, recurse into that record and continue. If your void lookup counter ever exceeds two, you've found a `PermError` condition.

A Playbook for Proactive Auditing and Mitigation

An incident postmortem is only useful if it leads to systemic improvements. Recovering from our `PermError` cascade was easy—we just removed the offending `include` from our TXT record. Preventing the next one requires a real strategy.

Monitoring vs. Flattening vs. Macros

Constant monitoring is the baseline. Your SPF record is not a static asset; it's a graph of dependencies you don't control. A third-party vendor can change their SPF record, introduce a nested include, or let a domain expire without telling you. This needs to be checked daily, automatically.

Some will suggest 'SPF flattening' as a solution. This technique replaces all `include` mechanisms with the actual `ip4` and `ip6` addresses from the included records. This is a terrible idea. While it eliminates the lookup count and void lookup risk, it creates a brittle, static record. The moment SendGrid or Google adds a new outbound IP address, your flattened record becomes dangerously outdated, and legitimate emails will start getting hard `fail` results from DMARC. You've traded a `PermError` for deliverability failure.

For complex environments, SPF macros are the technically superior, albeit more complex, solution. Defined in RFC 7208, macros allow you to create dynamic records. For example, you can use `%{i}` to insert the sender's IP address directly into a DNS query. This allows for highly targeted lookups (e.g., `exists:%{i}.spf.example.com`) that can return a result with a single query, drastically reducing your reliance on a long chain of generic `include` statements. It's an advanced tactic, but it's how the pros manage sprawl.

The takeaway

Our silent `PermError` was a painful but valuable lesson: SPF records are not 'set and forget' configurations. They are living documents that represent a chain of trust and dependency across the internet. A broken link in that chain, even one that seems inconsequential, can have a disproportionate impact on how the world sees your email.

Don't wait for your partners to tell you your mail is failing. Continuous, recursive monitoring of your entire SPF authentication chain isn't a luxury; it's a core operational requirement for anyone serious about deliverability. Whether you build your own auditing scripts or use a platform like MailSleuth.AI to watch your DNS authentication records, you simply cannot afford to fly blind.

#spf#email-authentication#dns#dmarc#infosec#postmortem#spf-permerror-void-lookup
MailSleuth Research
Email Security Team

We dissect phishing campaigns and email infrastructure so you don't have to.