Diagnostic Email Authentication

SPF record has a duplicate or misplaced all mechanism

Two email vendors, two SPF snippets, one merged record, and now there are two "all" tokens sitting in it, or the "all" landed somewhere in the middle instead of at the end. The record looks fine at a glance. But SPF reads left to right and stops at the first match, so whichever "all" comes first quietly becomes your entire policy, and everything typed after it is never even looked at.

Email Authentication Python and Node.js Safe by default (dry run)
Two envelopes on a table
Photo by Markus Spiske on Unsplash
The short answer

SPF evaluates mechanisms in order and stops at the first one that matches. The "all" mechanism always matches, so it must appear exactly once, at the very end. If a record has two "all" tokens, or has "all" before an include or other mechanism, everything after the first "all" is dead code that receivers are required to ignore, and your real policy gets silently overridden by whichever "all" comes first. Rebuild the record as one v=spf1 line with every include listed first and a single qualified "all" placed last, for example v=spf1 include:_spf.google.com include:sendgrid.net -all.

The problem in plain words

An SPF record is a short list of rules that says which mail servers are allowed to send for your domain. A receiving mail server reads that list from left to right, one rule at a time, and stops as soon as one rule matches the server that sent the message. Whatever result that first matching rule carries, pass, softfail, or fail, is the final answer.

The "all" rule is special because it matches everything, always. That is exactly its job, it is the catch-all fallback at the end of the list for senders that matched nothing else. But because it matches everything, if "all" shows up anywhere except the very last spot, evaluation stops right there. Every include, every other mechanism written after it never gets checked. And if there are two "all" tokens, only the first one ever runs. The second is just text sitting in the record, doing nothing.

This usually happens quietly. Someone merges the SPF snippet from a new email tool into an existing record, pastes it in the wrong spot, and the record still looks like valid SPF at a glance. Nothing errors out in the DNS panel. The mistake only shows up later, as legitimate mail failing SPF or spoofed mail passing it, and neither looks like an SPF problem on the surface.

include: _spf.google.com ~all matches, stop here include:sendgrid.net never reached -all dead code SoftFail wrong result the first all wins, no matter what follows it
SPF stops at the first matching mechanism. Because "all" always matches, an early "~all" wins the whole evaluation, and the SendGrid include plus the trailing "-all" are never reached.

Why it happens

RFC 7208 section 4.6.2 requires evaluators to stop at the first mechanism that matches, and section 5.1 defines "all" as matching everything unconditionally. Between those two rules, order is not a style choice, it decides which policy actually applies.

The key insight

SPF does not "combine" or "merge" mechanisms across an "all". It stops. The record is really two records glued together, and only the part before the first "all" ever runs. Anything after that first "all", another include, another qualifier, is invisible to every mail server checking your domain.

The fix, as a flow

The record does not need restructuring, it needs reordering. Collect every include mechanism your domain actually needs, whatever order you like, and put them first. Then add exactly one "all" mechanism, with the qualifier you intend, at the very end. There is only ever one v=spf1 record for a domain, and it only ever needs one "all", in the last position.

include: _spf.google.com include: sendgrid.net more includes as needed, any order -all exactly one, last every include checked first
Every legitimate sender's include is listed before the single "all" mechanism, so SPF checks all of them before it ever reaches the catch-all default.

How to fix it

1

Pull the current record and count the "all" tokens

Query the domain's TXT records and isolate the one starting with v=spf1. Compare answers from two resolvers to rule out stale caching before you decide the record is actually broken.

Terminal
check-spf.sh
dig +short TXT example.com | grep spf1
dig +short TXT example.com @8.8.8.8 | grep spf1

# count how many "all" tokens the record actually has
dig +short TXT example.com | tr ' ' '\n' | grep -c '^[+\-~?]\?all$'
# a count greater than 1 confirms a duplicate all mechanism
2

List every sending source that needs to be authorized

Write down every mail platform, ESP, and CRM that sends mail on behalf of the domain, and collect the exact include mechanism each one asks for, such as include:_spf.google.com or include:sendgrid.net.

3

Rebuild the record with the includes first and one all last

Put v=spf1 first, then every include mechanism in any order, and never place "all" between them. Finish with exactly one qualifier plus "all". Use -all once every legitimate sender is confirmed working, or ~all while you are still testing. Avoid +all, which lets anyone spoof the domain, and avoid a bare ?all unless that neutral result is genuinely what you want.

DNS record
dns-record.txt
# Broken record (two all tokens, one mid-record)
Type: TXT  Name: @  Value: "v=spf1 include:_spf.google.com ~all include:sendgrid.net -all"

# Corrected record (one all, placed last)
Type: TXT  Name: @  Value: "v=spf1 include:_spf.google.com include:sendgrid.net -all"
4

Edit the existing TXT record at the DNS host

Open the DNS host, for example the Cloudflare dashboard or API, and edit the existing TXT record at the apex (the "@" row for example.com) so there is exactly one TXT value that starts with v=spf1 and ends in exactly one "all" term. Do not create a second TXT record starting with v=spf1, RFC 7208 only permits one SPF record per domain, and having two is its own PermError.

5

Save and let the TTL catch up

Publish the change. If you plan to keep iterating on the record, drop the TTL to something short like 300 seconds first, so the next edit propagates quickly.

Watch the qualifier, not just the count

Two "all" tokens with different qualifiers is the dangerous version of this bug. If one is a bare "all" or "+all" and the other is "-all", whichever comes first decides everything, and a "+all" appearing first means anyone can spoof the domain and still pass SPF.

How to check it worked

Re-query the record and confirm exactly one "all", and that it sits at the very end.

Terminal
verify-spf.sh
dig +short TXT example.com
# expect: "v=spf1 include:_spf.google.com include:sendgrid.net -all"

dig +short TXT example.com | tr ' ' '\n' | grep -c '^[+\-~?]\?all$'
# expect: 1

# also check with an online validator, e.g. MXToolbox SPF record check,
# and confirm it reports no "too many all mechanisms" or PermError warning

Then send a real test message through each authorized source and check the Authentication-Results: header on the receiving side. Every legitimate sender should show spf=pass, and after you tighten the record to -all, a spoofed or unauthorized sender should show spf=fail.

The full code

Here is a small script in each language that fetches the SPF record, runs the pure check, and, when told to, repairs the record through the Cloudflare API. It stays in dry run by default so it only reports what it would change.

View this code on GitHub Full runnable folder with tests in the dns-fixes repo.

spf_all_mechanism.py
"""Detect a duplicate or misplaced all mechanism in an SPF record and
optionally repair it via Cloudflare. Safe by default. Set DRY_RUN=false
to let it write.
"""
import os
import logging

logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("spf_all_mechanism")

DNS_DOMAIN = os.environ.get("DNS_DOMAIN", "example.com")
CLOUDFLARE_API_TOKEN = os.environ.get("CLOUDFLARE_API_TOKEN", "")
CLOUDFLARE_ZONE_ID = os.environ.get("CLOUDFLARE_ZONE_ID", "")
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"

CF_API = "https://api.cloudflare.com/client/v4"


def check_spf_all_mechanism(spf_record):
    """Pure decision function. No I/O.

    Input: raw SPF TXT record string, e.g.
      'v=spf1 include:_spf.google.com ~all include:sendgrid.net -all'

    Output: {
      'ok': bool,
      'all_count': int,
      'all_position_ok': bool,   # True if the (first) all-token is the last token
      'unreachable_tokens': list[str],  # tokens after the first all-token
      'issue': str | None        # 'duplicate_all' | 'all_not_last' | None
    }
    """
    tokens = spf_record.strip().split()
    if tokens and tokens[0].lower() == "v=spf1":
        tokens = tokens[1:]

    all_indexes = [i for i, t in enumerate(tokens) if t.lstrip("+-~?").lower() == "all"]
    all_count = len(all_indexes)

    if all_count == 0:
        return {
            "ok": False,
            "all_count": 0,
            "all_position_ok": False,
            "unreachable_tokens": [],
            "issue": "all_not_last",
        }

    first_all_index = all_indexes[0]
    all_position_ok = first_all_index == len(tokens) - 1
    unreachable_tokens = tokens[first_all_index + 1:]

    issue = None
    if all_count > 1:
        issue = "duplicate_all"
    elif not all_position_ok:
        issue = "all_not_last"

    return {
        "ok": issue is None,
        "all_count": all_count,
        "all_position_ok": all_position_ok,
        "unreachable_tokens": unreachable_tokens,
        "issue": issue,
    }


def rebuild_spf_record(spf_record, qualifier="-"):
    """Rebuild a corrected record: dedupe/move all to the end with the chosen qualifier."""
    tokens = spf_record.strip().split()
    prefix = []
    if tokens and tokens[0].lower() == "v=spf1":
        prefix = [tokens[0]]
        tokens = tokens[1:]
    else:
        prefix = ["v=spf1"]

    kept = [t for t in tokens if t.lstrip("+-~?").lower() != "all"]
    return " ".join(prefix + kept + [f"{qualifier}all"])


def fetch_spf_record(domain):
    """Query TXT records and return the one starting with v=spf1. Requires network."""
    import dns.resolver

    resolver = dns.resolver.Resolver()
    answer = resolver.resolve(domain, "TXT")
    for rdata in answer:
        text = b"".join(rdata.strings).decode("utf-8", "ignore")
        if text.lower().startswith("v=spf1"):
            return text
    return None


def find_spf_record_id(domain):
    """Find the SPF TXT record via the Cloudflare API."""
    import requests

    headers = {"Authorization": f"Bearer {CLOUDFLARE_API_TOKEN}"}
    url = f"{CF_API}/zones/{CLOUDFLARE_ZONE_ID}/dns_records"
    r = requests.get(url, headers=headers, params={"type": "TXT", "name": domain}, timeout=30)
    r.raise_for_status()
    for record in r.json().get("result", []):
        if record.get("content", "").lower().startswith("v=spf1") or \
           record.get("content", "").lower().startswith('"v=spf1'):
            return record["id"]
    return None


def replace_spf_record(domain, record_id, corrected_content):
    """Replace the malformed SPF TXT record with the corrected content."""
    import requests

    headers = {"Authorization": f"Bearer {CLOUDFLARE_API_TOKEN}", "Content-Type": "application/json"}
    url = f"{CF_API}/zones/{CLOUDFLARE_ZONE_ID}/dns_records/{record_id}"

    if DRY_RUN:
        log.info("[dry run] would update TXT record %s at %s to: %s", record_id, domain, corrected_content)
        return

    requests.patch(
        url, headers=headers,
        json={"type": "TXT", "name": domain, "content": corrected_content},
        timeout=30,
    ).raise_for_status()
    log.info("Updated SPF record for %s", domain)


def run():
    spf_record = fetch_spf_record(DNS_DOMAIN)
    if not spf_record:
        log.warning("No v=spf1 TXT record found for %s", DNS_DOMAIN)
        return

    result = check_spf_all_mechanism(spf_record)
    log.info("SPF for %s: %s", DNS_DOMAIN, result)

    if result["ok"]:
        log.info("Nothing to repair.")
        return

    corrected = rebuild_spf_record(spf_record, qualifier="-")
    log.info("Proposed corrected record: %s", corrected)

    if not (CLOUDFLARE_API_TOKEN and CLOUDFLARE_ZONE_ID):
        log.warning("Issue found but no Cloudflare credentials set. Skipping repair.")
        return

    record_id = find_spf_record_id(DNS_DOMAIN)
    if not record_id:
        log.warning("Could not find the SPF TXT record via the Cloudflare API.")
        return

    replace_spf_record(DNS_DOMAIN, record_id, corrected)


if __name__ == "__main__":
    run()
spf-all-mechanism.js
/**
 * Detect a duplicate or misplaced all mechanism in an SPF record and
 * optionally repair it via Cloudflare. Safe by default. Set DRY_RUN=false
 * to let it write.
 */
import { pathToFileURL } from "node:url";

const DNS_DOMAIN = process.env.DNS_DOMAIN || "example.com";
const CLOUDFLARE_API_TOKEN = process.env.CLOUDFLARE_API_TOKEN || "";
const CLOUDFLARE_ZONE_ID = process.env.CLOUDFLARE_ZONE_ID || "";
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
const CF_API = "https://api.cloudflare.com/client/v4";

/**
 * Pure decision function. No I/O.
 *
 * Input: raw SPF TXT record string, e.g.
 *   'v=spf1 include:_spf.google.com ~all include:sendgrid.net -all'
 *
 * Output: {
 *   ok: boolean,
 *   allCount: number,
 *   allPositionOk: boolean,     // true if the (first) all-token is the last token
 *   unreachableTokens: string[],// tokens after the first all-token
 *   issue: string | null        // 'duplicate_all' | 'all_not_last' | null
 * }
 */
export function checkSpfAllMechanism(spfRecord) {
  let tokens = spfRecord.trim().split(/\s+/);
  if (tokens.length && tokens[0].toLowerCase() === "v=spf1") {
    tokens = tokens.slice(1);
  }

  const allIndexes = [];
  tokens.forEach((t, i) => {
    if (t.replace(/^[+\-~?]/, "").toLowerCase() === "all") allIndexes.push(i);
  });
  const allCount = allIndexes.length;

  if (allCount === 0) {
    return { ok: false, allCount: 0, allPositionOk: false, unreachableTokens: [], issue: "all_not_last" };
  }

  const firstAllIndex = allIndexes[0];
  const allPositionOk = firstAllIndex === tokens.length - 1;
  const unreachableTokens = tokens.slice(firstAllIndex + 1);

  let issue = null;
  if (allCount > 1) issue = "duplicate_all";
  else if (!allPositionOk) issue = "all_not_last";

  return { ok: issue === null, allCount, allPositionOk, unreachableTokens, issue };
}

/** Rebuild a corrected record: dedupe/move all to the end with the chosen qualifier. */
export function rebuildSpfRecord(spfRecord, qualifier = "-") {
  let tokens = spfRecord.trim().split(/\s+/);
  let prefix = ["v=spf1"];
  if (tokens.length && tokens[0].toLowerCase() === "v=spf1") {
    prefix = [tokens[0]];
    tokens = tokens.slice(1);
  }
  const kept = tokens.filter((t) => t.replace(/^[+\-~?]/, "").toLowerCase() !== "all");
  return [...prefix, ...kept, `${qualifier}all`].join(" ");
}

/** Query TXT records and return the one starting with v=spf1. Requires network. */
async function fetchSpfRecord(domain) {
  const dns = await import("node:dns/promises");
  const records = await dns.resolveTxt(domain);
  for (const chunks of records) {
    const text = chunks.join("");
    if (text.toLowerCase().startsWith("v=spf1")) return text;
  }
  return null;
}

/** Find the SPF TXT record via the Cloudflare API. */
async function findSpfRecordId(domain) {
  const url = `${CF_API}/zones/${CLOUDFLARE_ZONE_ID}/dns_records?type=TXT&name=${domain}`;
  const res = await fetch(url, { headers: { Authorization: `Bearer ${CLOUDFLARE_API_TOKEN}` } });
  if (!res.ok) throw new Error(`Cloudflare list failed: ${res.status}`);
  const body = await res.json();
  for (const record of body.result || []) {
    const content = (record.content || "").toLowerCase();
    if (content.startsWith("v=spf1") || content.startsWith('"v=spf1')) return record.id;
  }
  return null;
}

/** Replace the malformed SPF TXT record with the corrected content. */
async function replaceSpfRecord(domain, recordId, correctedContent) {
  const headers = {
    Authorization: `Bearer ${CLOUDFLARE_API_TOKEN}`,
    "Content-Type": "application/json",
  };
  const url = `${CF_API}/zones/${CLOUDFLARE_ZONE_ID}/dns_records/${recordId}`;

  if (DRY_RUN) {
    console.log(`[dry run] would update TXT record ${recordId} at ${domain} to: ${correctedContent}`);
    return;
  }

  const res = await fetch(url, {
    method: "PATCH",
    headers,
    body: JSON.stringify({ type: "TXT", name: domain, content: correctedContent }),
  });
  if (!res.ok) throw new Error(`Cloudflare update failed: ${res.status}`);
  console.log(`Updated SPF record for ${domain}`);
}

async function run() {
  const spfRecord = await fetchSpfRecord(DNS_DOMAIN);
  if (!spfRecord) {
    console.warn(`No v=spf1 TXT record found for ${DNS_DOMAIN}`);
    return;
  }

  const result = checkSpfAllMechanism(spfRecord);
  console.log(`SPF for ${DNS_DOMAIN}:`, result);

  if (result.ok) {
    console.log("Nothing to repair.");
    return;
  }

  const corrected = rebuildSpfRecord(spfRecord, "-");
  console.log(`Proposed corrected record: ${corrected}`);

  if (!(CLOUDFLARE_API_TOKEN && CLOUDFLARE_ZONE_ID)) {
    console.warn("Issue found but no Cloudflare credentials set. Skipping repair.");
    return;
  }

  const recordId = await findSpfRecordId(DNS_DOMAIN);
  if (!recordId) {
    console.warn("Could not find the SPF TXT record via the Cloudflare API.");
    return;
  }

  await replaceSpfRecord(DNS_DOMAIN, recordId, corrected);
}

if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
  run().catch((err) => {
    console.error(err);
    process.exit(1);
  });
}

Add a test

The check is the part worth testing on its own, because it decides whether the script thinks a record is safe. It takes a plain string, no network, no DNS lookups, so the tests run in milliseconds.

test_spf_check.py
from spf_all_mechanism import check_spf_all_mechanism


def test_ok_when_single_all_last():
    record = "v=spf1 include:_spf.google.com include:sendgrid.net -all"
    result = check_spf_all_mechanism(record)
    assert result["ok"] is True
    assert result["all_count"] == 1
    assert result["all_position_ok"] is True
    assert result["issue"] is None


def test_duplicate_all_flagged():
    record = "v=spf1 include:_spf.google.com ~all include:sendgrid.net -all"
    result = check_spf_all_mechanism(record)
    assert result["ok"] is False
    assert result["all_count"] == 2
    assert result["issue"] == "duplicate_all"
    assert result["unreachable_tokens"] == ["include:sendgrid.net", "-all"]


def test_all_not_last_flagged():
    record = "v=spf1 all include:_spf.google.com -all"
    result = check_spf_all_mechanism(record)
    assert result["ok"] is False
    assert result["all_count"] == 2
    assert result["all_position_ok"] is False
    assert result["issue"] == "duplicate_all"


def test_two_all_tokens_anywhere():
    record = "v=spf1 a mx -all ~all"
    result = check_spf_all_mechanism(record)
    assert result["all_count"] == 2
    assert result["issue"] == "duplicate_all"
spf-check.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { checkSpfAllMechanism } from "./spf-all-mechanism.js";

test("ok when single all last", () => {
  const record = "v=spf1 include:_spf.google.com include:sendgrid.net -all";
  const result = checkSpfAllMechanism(record);
  assert.equal(result.ok, true);
  assert.equal(result.allCount, 1);
  assert.equal(result.allPositionOk, true);
  assert.equal(result.issue, null);
});

test("duplicate all flagged", () => {
  const record = "v=spf1 include:_spf.google.com ~all include:sendgrid.net -all";
  const result = checkSpfAllMechanism(record);
  assert.equal(result.ok, false);
  assert.equal(result.allCount, 2);
  assert.equal(result.issue, "duplicate_all");
  assert.deepEqual(result.unreachableTokens, ["include:sendgrid.net", "-all"]);
});

test("all not last flagged", () => {
  const record = "v=spf1 all include:_spf.google.com -all";
  const result = checkSpfAllMechanism(record);
  assert.equal(result.ok, false);
  assert.equal(result.allCount, 2);
  assert.equal(result.allPositionOk, false);
  assert.equal(result.issue, "duplicate_all");
});

test("two all tokens anywhere", () => {
  const record = "v=spf1 a mx -all ~all";
  const result = checkSpfAllMechanism(record);
  assert.equal(result.allCount, 2);
  assert.equal(result.issue, "duplicate_all");
});

Case studies

Vendor migration

The record that gained a second vendor and a second all

A marketing team added a new email tool and pasted its recommended SPF snippet straight into the existing record without removing the old "all" first. The result had two "all" tokens, one softfail early in the record and one hard fail at the very end that could never run. Newsletter mail from the new tool started landing in spam because the softfail was the only result SPF ever returned.

Running the check against the live record showed all_count: 2 immediately. Rebuilding the record with both includes first and a single -all at the end fixed delivery on the next send.

Manual edit

The include that was quietly dead code for months

Someone added a transactional email provider's include mechanism to the domain's SPF record, but pasted it after the existing "-all" instead of before it. The record looked complete, the include was clearly present in the text, but SPF had already stopped at "-all" every time, so that provider's mail had been failing SPF the entire time.

A quick token count confirmed the "-all" was not the last item. Moving the include ahead of "-all" and re-publishing the record fixed authentication for that provider without touching anything else.

What good looks like

A healthy SPF record has exactly one "all" mechanism, sitting as the very last token, with every include and other mechanism listed ahead of it. Every legitimate sender's include gets evaluated before SPF ever reaches the catch-all, and the qualifier on that single "all" is the one policy that actually decides the domain's authentication outcome.

FAQ

Why does having two all mechanisms in my SPF record cause problems?

SPF reads a record left to right and stops at the first mechanism that matches. The all mechanism always matches, so whichever all comes first decides the whole result. Anything written after that first all, including a second all with a stricter rule, is dead code that receivers must ignore under RFC 7208.

What happens if all is not the last mechanism in the record?

Every mechanism after that all is unreachable. If you have an include for a sending service listed after the all, SPF will never actually check it, because evaluation already stopped and returned a result at the all. That sending service may fail SPF even though its include looks present in the record.

How do I fix an SPF record with a duplicate or misplaced all?

List every sending source your domain uses as an include mechanism, place them one after another with no all mixed in between, and add exactly one qualifier plus all at the very end, such as -all. Save that as the single v=spf1 TXT record for the domain and confirm with dig that only one all remains and it is the last token.

Related field notes

Citations

On the problem:

  1. RFC 7208, Sender Policy Framework (SPF) for Authorizing Use of Domains in Email, Version 1. datatracker.ietf.org/doc/html/rfc7208
  2. Google Workspace Admin Help: About SPF records. knowledge.workspace.google.com/admin/security/about-spf-records
  3. Google Workspace Admin Help: Troubleshoot SPF issues. knowledge.workspace.google.com/admin/security/troubleshoot-spf-issues

On the solution:

  1. Google Workspace Admin Help: Set up SPF. support.google.com/a/answer/33786
  2. Cloudflare DNS Records API: Update DNS Record. developers.cloudflare.com/api/operations/dns-records-for-a-zone-update-dns-record
  3. Cloudflare: Email Security Best Practices, configuring SPF in a shared environment. developers.cloudflare.com/dns/manage-dns-records/how-to/configure-spf-in-a-shared-environment

Stuck on a tricky one?

If you have a DNS, email deliverability, or certificate problem you would rather hand off, this is the kind of work I do. Message me and we can work through it together.

Contact me on LinkedIn

Did this fix your mail delivery?

If this saved you from spoofed mail or lost newsletters, you can buy me a coffee. It is the best way to keep these field notes free and growing.

Buy me a coffee on Ko-fi

Back to all DNS and Domains field notes