Internet Protocols · Email Protocols

SPF Evaluation and Its Ten-Lookup Budget

How a receiver matches a sending IP against a domain policy written in TXT records, why the recursion limit exists, and what each result value actually permits.

SPF answers one question: is this IP address authorized to send mail for this domain? The answer is computed by a function that takes the connecting IP, the envelope sender’s domain, and a set of DNS records, and returns one of a small number of result codes. The mechanism is simple in outline and awkward in practice, because it turns a DNS zone into a program and then has to bound the program’s execution.

The record and the check

The policy lives in a TXT record at the domain, beginning with the version string v=spf1. A record is a sequence of terms, each of which either matches or does not, and evaluation proceeds left to right until a term matches. The matching term’s qualifier determines the result.

example.com.  TXT  "v=spf1 ip4:192.0.2.0/24 include:_spf.provider.example -all"

Four qualifiers exist. + is pass, - is fail, ~ is softfail, and ? is neutral. A bare mechanism with no qualifier is +. The all mechanism matches every address, so a record ending in -all is a default-deny policy: anything not matched earlier fails.

The result codes are richer than the qualifiers. RFC 7208 defines none (no record exists), neutral, pass, fail, softfail, temperror (a transient DNS failure), and permerror (the record is syntactically invalid or exceeds a limit). The distinction between none and permerror matters: a domain with no SPF record has made no statement, while a domain with a broken record has made a statement that cannot be evaluated, and receivers treat the second as suspicious.

The check is performed against the envelope sender’s domain, not the From header. That is the same split described in Envelope and Header Addresses in SMTP Delivery, and it is why SPF alone cannot stop a forgery that uses a different envelope domain than the one displayed to the user.

Mechanisms, and which ones cost a lookup

The mechanism set is small, and the cost of each is the thing that constrains real records.

all matches unconditionally and costs nothing. ip4 and ip6 compare the address against a CIDR block and cost nothing. These are the cheap terms, and a record built only from them is evaluated entirely in memory after one DNS query.

a matches if the domain, or an explicitly named domain, has an address record matching the IP. mx matches if any exchanger for the domain has an address record matching the IP. ptr matches if the reverse lookup of the IP yields a name within the domain. exists matches if a DNS lookup of a constructed name returns any record at all. include recurses into another domain’s record and matches if that record’s evaluation returns pass.

Every one of those five causes at least one additional DNS query. That is the origin of the budget.

There is a sixth mechanism, redirect, which is a modifier rather than a mechanism: if no term in the record matches, evaluation continues at another domain’s record. It also costs a query and counts against the same budget.

v=spf1 a mx -all

This record is evaluated with one query for the TXT record, one for the a mechanism, and one for mx, plus an address lookup for each exchanger that mx returns. On a domain with five exchangers, that is six or seven queries before the first term can be decided. Multiply by the number of messages and the DNS load is no longer trivial.

The ten-query limit and why it exists

RFC 7208 caps the number of terms that cause DNS queries (include, a, mx, ptr, exists, and redirect) at ten per evaluation. The limit is not per record; it is per check, accumulated across every include recursion.

The reason is denial of service. An include chain can be arbitrarily deep, and each level can name ten more includes. Without a bound, a single crafted record could cause a resolver to issue an unbounded number of queries per message, and a domain that receives mail could be made to amplify traffic against a third party’s nameservers. The ten-query limit converts an unbounded recursion into a bounded one.

The limit produces a specific and common failure. A domain that outsources mail to several providers writes a record with one include per provider. Each include may itself use several terms. At some point the eleventh querying term is reached, evaluation stops, and the result is permerror, which many receivers treat as a failure. The domain’s record looks reasonable and fails closed, and the diagnostic is a limit exceeded rather than a syntax error.

Two further limits compound the problem. The mx mechanism is allowed at most ten MX names, and each of those names is allowed at most ten address lookups. The ptr mechanism is allowed at most ten validated names. And RFC 7208 limits “void lookups”, meaning queries that return no records, to two per check, so a record with several include targets that do not exist exhausts the budget through absence rather than through content.

The practical consequence is that SPF records are a scarce resource. A record that needs to express more than about three providers’ worth of sending infrastructure has to be flattened: the providers’ IP ranges copied into the domain’s own record as ip4 and ip6 terms. Flattening costs nothing at evaluation time and costs maintenance, because the copied ranges go stale when the provider changes them. That trade of evaluation cost against staleness is the central operational problem of SPF.

Reading the result correctly

The result is not a verdict. It is an input to a policy decision, and the mapping is a choice the receiver makes.

pass means the IP is authorized by the domain’s own policy. It is evidence of authorization, not of message integrity: the domain’s record may be permissive, and the IP may be shared with thousands of other senders.

fail means the IP is not authorized. RFC 7208 recommends that a receiver reject or mark such a message, but does not require it, because SPF records break for legitimate reasons: a forwarder that did not rewrite the envelope, or a provider that forgot to publish a range.

softfail is the author’s way of saying “probably not authorized, but I am not certain”. It exists for domains in transition that cannot afford to reject legitimate mail. Receivers typically treat it as a scoring signal rather than a rejection.

neutral means the record made no assertion about this IP, which is what ?all produces. It is indistinguishable in effect from none for most receivers.

temperror must not be treated as a failure. It means the evaluation could not complete, because of a DNS timeout or a server failure, and the correct action is to defer the decision rather than reject the message. Receivers that map temperror to rejection produce false positives whenever a nameserver is slow.

permerror is the ambiguous one. It covers syntax errors, the query limit, multiple v=spf1 records at one name, and an include whose target has no record. Some of those are the domain’s fault and some are a receiver’s misreading, and the specification does not require a particular action. Deployed practice varies between treating it as a failure and treating it as no assertion at all.

The check_host algorithm in outline

The evaluation function is specified precisely enough to implement, and the shape of the implementation explains the failure modes.

import ipaddress

QUAL = {"+": "pass", "-": "fail", "~": "softfail", "?": "neutral"}

def check_host(ip, domain, lookup, budget, depth=0):
    """lookup(name) returns the TXT strings at name; budget is a one-element list
    shared across the whole recursion, so the ten-query limit is global."""
    if depth > 10 or budget[0] <= 0:
        return "permerror"
    records = [r for r in lookup(domain) if r.startswith("v=spf1")]
    if len(records) > 1:
        return "permerror"          # more than one v=spf1 record is a permanent error
    if not records:
        return "none"
    addr = ipaddress.ip_address(ip)
    for term in records[0].split()[1:]:
        qual, mech = "+", term
        if term[0] in "+-~?":
            qual, mech = term[0], term[1:]
        if mech == "all":
            return QUAL[qual]
        if mech.startswith("ip4:"):
            if addr.version == 4 and addr in ipaddress.ip_network(mech[4:], strict=False):
                return QUAL[qual]
        elif mech.startswith("include:"):
            budget[0] -= 1
            r = check_host(ip, mech[8:], lookup, budget, depth + 1)
            if r == "pass":
                return QUAL[qual]
            if r in ("none", "temperror", "permerror"):
                return "permerror"  # an include that cannot be evaluated poisons the check
    return "neutral"                # no term matched and no -all

The code omits the a, mx, ptr, exists, ip6, and redirect terms; each follows the same shape, either consulting lookup and comparing the result against addr or recursing into check_host with the shared budget.

The recursion into include is the part that produces surprising results. An include that returns anything other than pass does not simply fail to match; if it returns none, temperror, or permerror, the whole check becomes permerror. That rule means a single broken include anywhere in a provider chain invalidates the domain’s entire policy, including the terms before it that would otherwise have matched.

The budget is shared by reference across the recursion rather than reset at each level, which is what makes the limit global: ten querying terms for the entire check, however deeply nested. A per-level counter would allow ten queries at each of ten levels and defeat the purpose.

The redirect modifier is evaluated only after every mechanism in the record has failed to match, and it replaces the default result rather than adding to it. A record that has both redirect= and all is self-defeating: all matches everything, so the redirect can never be reached. Implementations differ on whether they treat the combination as a configuration error or silently ignore the redirect.

What the header records

The receiving server’s verdict is typically published in an Authentication-Results header, defined by RFC 8601. It carries the authserv-id of the server that made the decision, the method name spf, the result, and the identity it was evaluated against.

Authentication-Results: mx.example.net;
    spf=pass smtp.mailfrom=alice@example.com

The smtp.mailfrom property records the envelope sender. When the envelope sender is empty, the check is performed against the HELO name instead, and the property is smtp.helo. That fallback is why a message with a null return path can still carry an SPF result, and why the result’s meaning differs: a pass on a HELO identity says only that the sending host is authorized for the name it claimed, which is a much weaker statement than an authorized envelope sender.

The header is unauthenticated and trivially forgeable by the sender. It is meaningful only when the receiving boundary is trusted, that is, when the field was added by the last hop before local delivery. Any consumer that reads it from a message that arrived from outside is reading attacker-controlled text.

The limit on how much SPF can prove is what motivated a cryptographic alternative, described in DKIM Canonicalization and What It Actually Signs, and the combination of the two under one policy is the subject of DMARC Alignment Between Header and Envelope Domains.

References

  1. RFC 7208: Sender Policy Framework (SPF) for Authorizing Use of Domains in Email — IETF
  2. RFC 5321: Simple Mail Transfer Protocol — IETF
  3. RFC 8601: Message Header Field for Indicating Message Authentication Status — IETF