Skip to content

SPF 10-lookup overflow is position-dependent, not a flat failure: a lyft.com case study

TL;DR.

When an SPF record exceeds RFC 7208's 10-DNS-lookup limit, evaluation short-circuits on first match, so mechanisms before the budget cliff still return pass while everything after returns PermError. Diagnose by walking the cumulative lookup cost per top-level include and cross-checking with a real evaluator (pyspf); report the cliff position, not a flat "too many lookups".

Public SPF checkers report lookup-limit overflow as a flat failure -- "too many DNS lookups, record invalid." That framing is wrong and it misroutes the fix. An over-budget SPF record is not uniformly dead: it passes for senders whose mechanism appears before the budget is exhausted and returns PermError for every sender after. The correct answer to "is SPF broken?" is a per-sender table, not a boolean.

This is a live worked example against lyft.com, observed 2026-08-14. Everything below is public DNS data reproducible with one dig.

Why position matters (straight from the spec)

Two clauses of RFC 7208 combine into the behavior, and neither is surprising alone -- it's the interaction that catches people.

Section 4.6.4, DNS Lookup Limits:

The following terms cause DNS queries: the "include", "a", "mx", "ptr", and "exists" mechanisms, and the "redirect" modifier. SPF implementations MUST limit the total number of those terms to 10 during SPF evaluation, to avoid unreasonable load on the DNS. If this limit is exceeded, the implementation MUST return "permerror".

Section 4.6.2, Mechanisms:

Each mechanism is considered in turn from left to right. [...] If it matches, processing ends and the qualifier value is returned as the result of that record. If it does not match, processing continues with the next mechanism. If it returns an exception, mechanism processing ends and the exception value is returned.

The budget is counted during evaluation, and evaluation stops at the first match. Terms never reached are never evaluated and never counted. So the cliff is at a fixed cumulative position in the record, and which side of it a sender lands on is what decides its result. Reordering the record changes who breaks.

The record

$ dig +short TXT lyft.com @8.8.8.8 | grep spf1
"v=spf1 include:qemailserver.com include:docebosaas.com include:_spf.google.com
 include:mailgun.org include:amazonses.com include:mail.zendesk.com
 include:spf_c.oraclecloud.com include:usermail.zohocreator.com
 include:_spf.salesforce.com -all"

241 octets, a single character-string (so no §3.3 multi-string concatenation subtlety), syntactically valid, correct terminal -all, zero void lookups. Nothing in the record text hints at the problem. Syntax validators pass it.

Pass 1: static budget walk

Recursively resolve, counting 1 per DNS-querying term plus everything nested beneath it, and attribute cost to each top-level mechanism so the cliff is visible:

1 qemailserver.com 2 2 OK
2 docebosaas.com 1 3 OK
3 _spf.google.com 1 4 OK
4 mailgun.org 5 9 OK
5 amazonses.com 1 10 OK (budget exactly exhausted)
6 mail.zendesk.com 1 11 OVER
7 spf_c.oraclecloud.com 3 14 OVER
8 usermail.zohocreator.com 2 16 OVER
9 _spf.salesforce.com 2 18 OVER

18 required, 10 allowed. Nesting depth is where the budget actually goes, and it is invisible in the record text. One include:mailgun.org costs 5 lookups (mailgun.org -> _spf.mailgun.org -> _spf1/_spf2), consuming half the budget for a single vendor. Cost is not proportional to visual size.

Pass 2: dynamic evaluation

Don't trust your own counter. Run a conforming implementation (pyspf) against one representative IP per authorized sender -- the IPs come from the ip4: blocks inside each include, so no live sending is needed:

import spf  # pip install pyspf dnspython

for ip, who in [("50.233.164.120", "qemailserver"), ("107.20.91.250", "docebo"),
                ("74.125.1.1", "google"),       ("209.61.151.10", "mailgun"),
                ("54.240.0.10", "amazon-ses"),  ("185.12.80.10", "zendesk"),
                ("147.154.59.200", "oracle"),   ("136.143.188.10", "zoho"),
                ("13.111.0.1", "salesforce")]:
    print(who, spf.check(i=ip, s="noreply@lyft.com", h="mail.lyft.com"))
qemailserver ('pass', 250, 'sender SPF authorized')
docebo       ('pass', 250, 'sender SPF authorized')
google       ('pass', 250, 'sender SPF authorized')
mailgun      ('pass', 250, 'sender SPF authorized')
amazon-ses   ('pass', 250, 'sender SPF authorized')
zendesk      ('unknown', 550, 'SPF Permanent Error: Too many DNS lookups')
oracle       ('unknown', 550, 'SPF Permanent Error: Too many DNS lookups')
zoho         ('unknown', 550, 'SPF Permanent Error: Too many DNS lookups')
salesforce   ('unknown', 550, 'SPF Permanent Error: Too many DNS lookups')

The dynamic cliff lands between #5 and #6 -- exactly where the static walk put it. If the two passes agree, the finding is solid; if they disagree, your counter is wrong. Nesting, redirect=, macro exists: terms, and per-implementation void-lookup handling are all easy to miscount by hand.

Two consequences, both easy to miss

Deliverability. _dmarc.lyft.com publishes v=DMARC1; p=reject; pct=100. A PermError is not a pass, so it cannot satisfy DMARC's SPF leg -- for the four senders past the cliff, DKIM alignment is the only surviving mechanism. Always fetch _dmarc.<domain> before claiming impact; under p=none this is noise, under p=reject it is potentially rejected mail.

The terminal -all becomes unreachable for those senders. Per §4.6.2, an exception ends mechanism processing and returns the exception -- so evaluation never reaches -all for anything past the cliff. The record's explicit "reject everything else" intent is simply not applied to them. Receiver handling of permerror is not specified as equivalent to fail and varies in practice, and §2.6.7 says only that it "definitely requires DNS operator intervention". So overflow partially defeats the anti-spoofing posture the author intended, in a direction that depends on the receiver.

Checking DKIM: confirmable, not refutable

Probing a guessed selector list can confirm coverage but never refute it, because vendors use custom selectors:

for s in google zendesk1 zendesk2 s1 s2 selector1 selector2 mailgun k1 smtp dkim; do
  printf '%s: %s\n' "$s" "$(dig +short TXT $s._domainkey.lyft.com @8.8.8.8 | head -1)"
done

For lyft.com this found google (inline RSA key), zendesk1/zendesk2 (CNAME-delegated to zendesk.com), mailgun, and k1 (dkim.mcsv.net) -- so Zendesk, the first sender past the cliff, is very likely rescued by DKIM. Oracle, Zoho, and Salesforce returned nothing for any guessed selector, which is inconclusive, not confirmation of breakage. Report it that way.

The authoritative source is the DMARC aggregate feed: the record publishes rua=, and those reports show the real per-source pass/fail split directly. One look there beats any amount of selector guessing.

Fixing

Target the deepest nesting first, not the longest-looking include. Flattening a multi-level vendor include into ip4: blocks recovers the most budget per edit -- here, flattening Mailgun alone reclaims 4 of the 8 overage -- but it trades the lookup limit for staleness risk when the vendor rotates ranges, so it wants a scheduled re-flattening job rather than a one-time hand edit. Removing genuinely unused senders is strictly better wherever it's available; a nine-vendor SPF record usually has at least one dead entry from a discontinued tool. Google documents the same limit and remedies for Workspace senders.

Gotchas worth internalizing

  • The record can be syntactically perfect, well under the 255-octet character-string limit, single-string, with a correct terminal -all, and still be fully broken for half its senders.
  • Query two independent public resolvers (8.8.8.8 and 1.1.1.1) before concluding. They return TXT sets in different orders, so never rely on dig output ordering -- only the SPF record's own left-to-right term order matters.
  • exists:%{i}._spf.mta.salesforce.com-style terms (macros, §7) cost a lookup at evaluation time. A record can look under budget when read statically and go over when actually evaluated.
  • Because the limit is positional, a purely cosmetic reorder of include: terms silently changes which senders break. Treat SPF term order as load-bearing configuration.
No signals yet