Diagnostic Email Authentication

DMARC record missing or malformed

Most domains never publish a DMARC policy at all. On the ones that try, a stray space, a missing semicolon, or the tags in the wrong order is enough to break the record. Either way, mail receivers see no usable DMARC policy for your domain, so spoofed mail using your name gets no enforcement and you get no reports telling you it is happening. Here is why that happens and a script that checks the record and fixes it.

Python and Node.js Cloudflare DNS API Safe by default (dry run)
An envelope button
Photo by Mariia Shalabaieva on Unsplash
The short answer

Per RFC 7489, a domain's DMARC record must be a single TXT string at _dmarc.yourdomain.com, starting with v=DMARC1 and followed right away by a p= tag set to none, quarantine, or reject. Those are the only two required tags, and they must appear in that order. If the record is absent, if there are two conflicting TXT records at that name, or if v or p is missing, misordered, or misspelled, receivers treat the domain as having no DMARC policy at all. The fix is to publish exactly one syntactically correct TXT record, starting at p=none to only collect reports, then moving to quarantine and reject once those reports look clean. Full code, tests, and a dry run guard are below.

The problem in plain words

DMARC is the policy that tells other mail servers what to do with mail that claims to be from your domain but fails to check out. It lives at one specific spot in DNS: a TXT record at _dmarc.yourdomain.com. When someone receives mail claiming to be from your domain, their mail server looks up that one record to see what you want done with mail that fails your checks.

That only works if the record is there and it is written correctly. Most domains simply never add it, so there is nothing to find. Others add something, but a typo breaks the grammar the RFC requires, such as a missing p= tag, the tags written in the wrong order, or a second TXT record sitting at the same name that conflicts with the first. In every one of these cases, the mail receiver cannot read a usable policy, so it falls back to doing nothing special with mail that fails, which is exactly the case DMARC exists to prevent.

Receiver checks DMARC TXT _dmarc.example.com No record found nothing published at _dmarc Broken record "p=none; v=DMARC1" (wrong order) no usable policy, nothing to enforce Spoofed mail passes through unchecked
Whether the record is missing or just badly formed, the outcome for the receiver is the same. There is no usable DMARC policy, so mail that fails SPF or DKIM checks gets no special treatment, and you never hear about it.

Why it happens

The key insight

RFC 7489 only requires two tags, v and p, but it is strict about how they appear. v=DMARC1 must come first, and p= must come immediately after with a value of none, quarantine, or reject. A record missing either tag, with them out of order, or with more than one competing string at _dmarc, is not "partially working." Receivers treat it exactly the same as no record at all. There is no partial credit for a record that almost parses.

The fix, as a flow

Query the TXT record at _dmarc.yourdomain.com directly, so you see exactly what is published rather than what a cache remembers. Check that exactly one string comes back, that it starts with v=DMARC1, and that a valid p= tag follows right after. If the record is missing, or broken in any of those ways, publish or replace it with one clean TXT record. Start the policy at p=none so you only collect aggregate reports at first, then tighten it once those reports confirm your real mail is aligned.

Query TXT at _dmarc.domain Check count and v= / p= tag grammar Missing or malformed? Repair needed? yes no, valid already Create or fix, start at p=none
A domain with one valid DMARC record is left alone. A domain with none, or with a broken one, gets a single new record created or the bad one replaced, starting at p=none.

How to fix it

1

Confirm the problem with a direct DNS query

Query the TXT record straight from a public resolver so a stale local cache cannot hide the problem. A healthy domain returns exactly one string starting with v=DMARC1; followed by a valid p= value. Empty output, a string not starting with v=DMARC1, or two or more strings, all confirm the bug.

Terminal
check-dmarc.sh
dig +short TXT _dmarc.example.com

# bypass any local caching, ask a public resolver directly
dig @1.1.1.1 +short TXT _dmarc.example.com

# a second opinion from a different tool
nslookup -type=TXT _dmarc.example.com

# count how many strings came back, anything other than 1 is a bug
dig +short TXT _dmarc.example.com | wc -l
2

Write one syntactically valid record

The record must be a single TXT string, v=DMARC1 first, then p= right after with a value of none, quarantine, or reject. Start at p=none so you only collect reports at first. If a broken record already exists, this replaces it. It does not sit alongside it.

DNS record
records-before-and-after
# BEFORE (bug): nothing published, or a broken string like this
# (missing altogether)
# or, tags in the wrong order and p= missing a value:
_dmarc.example.com.    TXT   "p=none; v=DMARC1"

# AFTER: one valid record, v first, p right after, start at p=none
_dmarc.example.com.    TXT   "v=DMARC1; p=none; rua=mailto:dmarc-reports@example.com"
3

Publish it through the Cloudflare dashboard or API

In the Cloudflare dashboard this is Dashboard, select the zone, DNS, Records, Add record: Type TXT, Name _dmarc, Content v=DMARC1; p=quarantine; rua=mailto:dmarc-reports@example.com; pct=100, TTL Auto, Save. If a _dmarc TXT record already exists, edit that one instead of adding a second, since two records at the same name is itself invalid. The same steps work through the API.

Terminal
cloudflare-api.sh
# find any existing TXT record at _dmarc for the domain
curl -s -H "Authorization: Bearer $CF_TOKEN" \
  "https://api.cloudflare.com/client/v4/zones/$ZONE_ID/dns_records?type=TXT&name=_dmarc.example.com" \
  | jq '.result[]'

# if a broken record exists, update it in place by its id
curl -X PUT \
  -H "Authorization: Bearer $CF_TOKEN" \
  -H "Content-Type: application/json" \
  "https://api.cloudflare.com/client/v4/zones/$ZONE_ID/dns_records/$RECORD_ID" \
  -d '{"type":"TXT","name":"_dmarc","content":"v=DMARC1; p=quarantine; rua=mailto:dmarc-reports@example.com; pct=100","ttl":1}'

# if no record exists yet, create the new one
curl -X POST \
  -H "Authorization: Bearer $CF_TOKEN" \
  -H "Content-Type: application/json" \
  "https://api.cloudflare.com/client/v4/zones/$ZONE_ID/dns_records" \
  -d '{"type":"TXT","name":"_dmarc","content":"v=DMARC1; p=quarantine; rua=mailto:dmarc-reports@example.com; pct=100","ttl":1}'

How to check it worked

Re-run the same TXT lookup and confirm exactly one string is returned, starting with v=DMARC1; followed immediately by a valid p= value. Then validate the grammar with a DMARC-specific checker, and after a day or two, confirm that aggregate reports start arriving at the address in rua.

Terminal
verify.sh
# exactly one string, starting with v=DMARC1, should print
dig +short TXT _dmarc.example.com

# should print exactly "1"
dig +short TXT _dmarc.example.com | wc -l

# ask a second resolver directly to rule out a cached answer
dig @1.1.1.1 +short TXT _dmarc.example.com

A good result looks like a single quoted string: "v=DMARC1; p=quarantine; rua=mailto:dmarc-reports@example.com; pct=100". Run that string through an online DMARC checker, such as dmarcian's DMARC Inspector or MXToolbox's DMARC Lookup, and confirm it reports a valid DMARC record found, with no syntax warnings. After 24 to 48 hours, check that aggregate reports start arriving at the rua mailbox as XML attachments, since that confirms mail receivers are actually honoring the policy, not just that the DNS record parses.

The full code

Here is the complete checker and repair script in one file for each language. It queries the TXT record at _dmarc.{domain}, checks it against DMARC tag grammar, and, only when you turn dry run off, creates or repairs the record through the Cloudflare API.

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

dmarc_record_missing_or_malformed.py
"""Detect a missing, duplicated, or malformed DMARC TXT record at
_dmarc.{domain}, and optionally repair it via Cloudflare.

Safe by default. Set DRY_RUN=false to let it write.

Env vars:
  DNS_DOMAIN               the domain to check, e.g. "example.com"
  CLOUDFLARE_API_TOKEN     Cloudflare API token (only needed for repair)
  CLOUDFLARE_ZONE_ID       Cloudflare zone id (only needed for repair)
  DRY_RUN                  default "true"; set to "false" to actually write
"""
import os
import logging

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

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"

VALID_P_VALUES = {"none", "quarantine", "reject"}
DEFAULT_REPAIR_CONTENT = "v=DMARC1; p=quarantine; rua=mailto:dmarc-reports@{domain}; pct=100"


def validate_dmarc_record(txt_strings):
    """Pure decision function. No I/O.

    txt_strings: list of raw TXT strings found at _dmarc (empty list if none).

    Returns a dict:
      {"status": "missing" | "duplicate" | "invalid" | "valid",
       "reason": str or None,
       "tags": dict or None}

    Checks that exactly one string exists, that it parses per DMARC tag
    grammar (starts with v=DMARC1, has a p= tag immediately after with an
    allowed value, and no tag key repeats).
    """
    if len(txt_strings) == 0:
        return {"status": "missing", "reason": "no TXT record found at _dmarc", "tags": None}

    if len(txt_strings) > 1:
        return {"status": "duplicate", "reason": "more than one TXT record found at _dmarc", "tags": None}

    raw = txt_strings[0].strip().strip('"')
    parts = [p.strip() for p in raw.split(";") if p.strip() != ""]

    if len(parts) == 0:
        return {"status": "invalid", "reason": "empty record", "tags": None}

    tags = {}
    order = []
    for part in parts:
        if "=" not in part:
            return {"status": "invalid", "reason": f"tag '{part}' has no value", "tags": None}
        key, value = part.split("=", 1)
        key = key.strip()
        value = value.strip()
        if key in tags:
            return {"status": "invalid", "reason": f"tag '{key}' appears more than once", "tags": None}
        tags[key] = value
        order.append(key)

    if order[0] != "v" or tags.get("v") != "DMARC1":
        return {"status": "invalid", "reason": "record must start with v=DMARC1", "tags": tags}

    if len(order) < 2 or order[1] != "p":
        return {"status": "invalid", "reason": "p= tag must come immediately after v=DMARC1", "tags": tags}

    if tags.get("p") not in VALID_P_VALUES:
        return {"status": "invalid", "reason": "p= must be none, quarantine, or reject", "tags": tags}

    return {"status": "valid", "reason": None, "tags": tags}


def query_dmarc_txt(domain):
    """Return every TXT string found at _dmarc.{domain}."""
    import dns.resolver

    name = f"_dmarc.{domain}"
    try:
        answers = dns.resolver.resolve(name, "TXT")
    except dns.resolver.NXDOMAIN:
        return []
    except dns.resolver.NoAnswer:
        return []
    found = []
    for rdata in answers:
        text = b"".join(rdata.strings).decode("utf-8", errors="replace")
        found.append(text)
    return found


def list_dmarc_records(domain):
    """List the id and content of every TXT record at _dmarc via Cloudflare."""
    import requests

    headers = {"Authorization": f"Bearer {CLOUDFLARE_API_TOKEN}"}
    params = {"type": "TXT", "name": f"_dmarc.{domain}", "per_page": 100}
    r = requests.get(
        f"{CF_API}/zones/{CLOUDFLARE_ZONE_ID}/dns_records",
        headers=headers, params=params, timeout=30,
    )
    r.raise_for_status()
    return [{"id": rec["id"], "content": rec["content"]} for rec in r.json()["result"]]


def create_dmarc_record(domain, content):
    import requests

    headers = {"Authorization": f"Bearer {CLOUDFLARE_API_TOKEN}"}
    body = {"type": "TXT", "name": "_dmarc", "content": content, "ttl": 1}
    if DRY_RUN:
        log.info("[dry run] would create _dmarc TXT record: %s", content)
        return
    r = requests.post(
        f"{CF_API}/zones/{CLOUDFLARE_ZONE_ID}/dns_records",
        headers=headers, json=body, timeout=30,
    )
    r.raise_for_status()
    log.info("Created _dmarc TXT record: %s", content)


def update_dmarc_record(record_id, content):
    import requests

    headers = {"Authorization": f"Bearer {CLOUDFLARE_API_TOKEN}"}
    body = {"type": "TXT", "name": "_dmarc", "content": content, "ttl": 1}
    url = f"{CF_API}/zones/{CLOUDFLARE_ZONE_ID}/dns_records/{record_id}"
    if DRY_RUN:
        log.info("[dry run] would update record %s to: %s", record_id, content)
        return
    r = requests.put(url, headers=headers, json=body, timeout=30)
    r.raise_for_status()
    log.info("Updated record %s to: %s", record_id, content)


def run():
    txt_strings = query_dmarc_txt(DNS_DOMAIN)
    result = validate_dmarc_record(txt_strings)

    if result["status"] == "valid":
        log.info("_dmarc.%s already has a valid DMARC record. Nothing to do.", DNS_DOMAIN)
        return

    log.warning("_dmarc.%s is %s: %s", DNS_DOMAIN, result["status"], result["reason"])

    repair_content = DEFAULT_REPAIR_CONTENT.format(domain=DNS_DOMAIN)

    if not (CLOUDFLARE_API_TOKEN and CLOUDFLARE_ZONE_ID):
        log.warning("No Cloudflare credentials set. Not repairing, only reporting.")
        return

    zone_records = list_dmarc_records(DNS_DOMAIN)
    if len(zone_records) == 0:
        create_dmarc_record(DNS_DOMAIN, repair_content)
    elif len(zone_records) == 1:
        update_dmarc_record(zone_records[0]["id"], repair_content)
    else:
        # duplicate records: remove all but one, then fix the survivor
        for rec in zone_records[1:]:
            import requests
            headers = {"Authorization": f"Bearer {CLOUDFLARE_API_TOKEN}"}
            url = f"{CF_API}/zones/{CLOUDFLARE_ZONE_ID}/dns_records/{rec['id']}"
            if DRY_RUN:
                log.info("[dry run] would delete duplicate record %s", rec["id"])
            else:
                requests.delete(url, headers=headers, timeout=30).raise_for_status()
                log.info("Deleted duplicate record %s", rec["id"])
        update_dmarc_record(zone_records[0]["id"], repair_content)

    log.info("Done.")


if __name__ == "__main__":
    run()
dmarc-record-missing-or-malformed.js
/**
 * Detect a missing, duplicated, or malformed DMARC TXT record at
 * _dmarc.{domain}, and optionally repair it via Cloudflare.
 *
 * Safe by default. Set DRY_RUN=false to let it write.
 *
 * Env vars:
 *   DNS_DOMAIN               the domain to check, e.g. "example.com"
 *   CLOUDFLARE_API_TOKEN     Cloudflare API token (only needed for repair)
 *   CLOUDFLARE_ZONE_ID       Cloudflare zone id (only needed for repair)
 *   DRY_RUN                  default "true"; set to "false" to actually 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";

const VALID_P_VALUES = new Set(["none", "quarantine", "reject"]);

function defaultRepairContent(domain) {
  return `v=DMARC1; p=quarantine; rua=mailto:dmarc-reports@${domain}; pct=100`;
}

export function validateDmarcRecord(txtStrings) {
  // Pure decision function. No I/O.
  //
  // txtStrings: array of raw TXT strings found at _dmarc (empty array if none).
  //
  // Returns an object:
  //   { status: "missing" | "duplicate" | "invalid" | "valid",
  //     reason: string | null,
  //     tags: object | null }
  //
  // Checks that exactly one string exists, that it parses per DMARC tag
  // grammar (starts with v=DMARC1, has a p= tag immediately after with an
  // allowed value, and no tag key repeats).
  const strings = txtStrings || [];

  if (strings.length === 0) {
    return { status: "missing", reason: "no TXT record found at _dmarc", tags: null };
  }

  if (strings.length > 1) {
    return { status: "duplicate", reason: "more than one TXT record found at _dmarc", tags: null };
  }

  const raw = strings[0].trim().replace(/^"|"$/g, "");
  const parts = raw.split(";").map((p) => p.trim()).filter((p) => p !== "");

  if (parts.length === 0) {
    return { status: "invalid", reason: "empty record", tags: null };
  }

  const tags = {};
  const order = [];
  for (const part of parts) {
    const eq = part.indexOf("=");
    if (eq === -1) {
      return { status: "invalid", reason: `tag '${part}' has no value`, tags: null };
    }
    const key = part.slice(0, eq).trim();
    const value = part.slice(eq + 1).trim();
    if (Object.prototype.hasOwnProperty.call(tags, key)) {
      return { status: "invalid", reason: `tag '${key}' appears more than once`, tags: null };
    }
    tags[key] = value;
    order.push(key);
  }

  if (order[0] !== "v" || tags.v !== "DMARC1") {
    return { status: "invalid", reason: "record must start with v=DMARC1", tags };
  }

  if (order.length < 2 || order[1] !== "p") {
    return { status: "invalid", reason: "p= tag must come immediately after v=DMARC1", tags };
  }

  if (!VALID_P_VALUES.has(tags.p)) {
    return { status: "invalid", reason: "p= must be none, quarantine, or reject", tags };
  }

  return { status: "valid", reason: null, tags };
}

async function queryDmarcTxt(domain) {
  const dns = await import("node:dns/promises");
  const name = `_dmarc.${domain}`;
  try {
    const records = await dns.resolveTxt(name);
    return records.map((chunks) => chunks.join(""));
  } catch (err) {
    if (err.code === "ENOTFOUND" || err.code === "ENODATA") return [];
    throw err;
  }
}

async function listDmarcRecords(domain) {
  const headers = { Authorization: `Bearer ${CLOUDFLARE_API_TOKEN}` };
  const params = new URLSearchParams({ type: "TXT", name: `_dmarc.${domain}`, per_page: "100" });
  const res = await fetch(
    `${CF_API}/zones/${CLOUDFLARE_ZONE_ID}/dns_records?${params.toString()}`,
    { headers },
  );
  if (!res.ok) throw new Error(`Cloudflare list returned ${res.status}`);
  const body = await res.json();
  return body.result.map((rec) => ({ id: rec.id, content: rec.content }));
}

async function createDmarcRecord(domain, content) {
  const headers = {
    Authorization: `Bearer ${CLOUDFLARE_API_TOKEN}`,
    "Content-Type": "application/json",
  };
  if (DRY_RUN) {
    console.log(`[dry run] would create _dmarc TXT record: ${content}`);
    return;
  }
  const res = await fetch(`${CF_API}/zones/${CLOUDFLARE_ZONE_ID}/dns_records`, {
    method: "POST",
    headers,
    body: JSON.stringify({ type: "TXT", name: "_dmarc", content, ttl: 1 }),
  });
  if (!res.ok) throw new Error(`Cloudflare create returned ${res.status}`);
  console.log(`Created _dmarc TXT record: ${content}`);
}

async function updateDmarcRecord(recordId, content) {
  const headers = {
    Authorization: `Bearer ${CLOUDFLARE_API_TOKEN}`,
    "Content-Type": "application/json",
  };
  if (DRY_RUN) {
    console.log(`[dry run] would update record ${recordId} to: ${content}`);
    return;
  }
  const res = await fetch(`${CF_API}/zones/${CLOUDFLARE_ZONE_ID}/dns_records/${recordId}`, {
    method: "PUT",
    headers,
    body: JSON.stringify({ type: "TXT", name: "_dmarc", content, ttl: 1 }),
  });
  if (!res.ok) throw new Error(`Cloudflare update returned ${res.status}`);
  console.log(`Updated record ${recordId} to: ${content}`);
}

async function deleteRecord(recordId) {
  const headers = { Authorization: `Bearer ${CLOUDFLARE_API_TOKEN}` };
  if (DRY_RUN) {
    console.log(`[dry run] would delete duplicate record ${recordId}`);
    return;
  }
  const res = await fetch(`${CF_API}/zones/${CLOUDFLARE_ZONE_ID}/dns_records/${recordId}`, {
    method: "DELETE",
    headers,
  });
  if (!res.ok) throw new Error(`Cloudflare delete returned ${res.status}`);
  console.log(`Deleted duplicate record ${recordId}`);
}

async function run() {
  const txtStrings = await queryDmarcTxt(DNS_DOMAIN);
  const result = validateDmarcRecord(txtStrings);

  if (result.status === "valid") {
    console.log(`_dmarc.${DNS_DOMAIN} already has a valid DMARC record. Nothing to do.`);
    return;
  }

  console.warn(`_dmarc.${DNS_DOMAIN} is ${result.status}: ${result.reason}`);

  const repairContent = defaultRepairContent(DNS_DOMAIN);

  if (!(CLOUDFLARE_API_TOKEN && CLOUDFLARE_ZONE_ID)) {
    console.warn("No Cloudflare credentials set. Not repairing, only reporting.");
    return;
  }

  const zoneRecords = await listDmarcRecords(DNS_DOMAIN);
  if (zoneRecords.length === 0) {
    await createDmarcRecord(DNS_DOMAIN, repairContent);
  } else if (zoneRecords.length === 1) {
    await updateDmarcRecord(zoneRecords[0].id, repairContent);
  } else {
    for (const rec of zoneRecords.slice(1)) {
      await deleteRecord(rec.id);
    }
    await updateDmarcRecord(zoneRecords[0].id, repairContent);
  }

  console.log("Done.");
}

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 validation rule is the part most worth testing, because it decides whether the record is left alone or replaced. Because validate_dmarc_record is pure, the test needs no network and no DNS provider. It just feeds in plain lists of strings and checks the returned status.

test_dmarc_validate.py
from dmarc_record_missing_or_malformed import validate_dmarc_record


def test_missing_when_no_records():
    result = validate_dmarc_record([])
    assert result["status"] == "missing"


def test_duplicate_when_two_records():
    records = [
        "v=DMARC1; p=none",
        "v=DMARC1; p=reject",
    ]
    result = validate_dmarc_record(records)
    assert result["status"] == "duplicate"


def test_valid_record_with_p_none():
    result = validate_dmarc_record(["v=DMARC1; p=none; rua=mailto:dmarc@example.com"])
    assert result["status"] == "valid"
    assert result["tags"]["p"] == "none"


def test_invalid_when_p_before_v():
    result = validate_dmarc_record(["p=none; v=DMARC1"])
    assert result["status"] == "invalid"


def test_invalid_when_p_missing():
    result = validate_dmarc_record(["v=DMARC1; rua=mailto:dmarc@example.com"])
    assert result["status"] == "invalid"


def test_invalid_when_p_value_not_allowed():
    result = validate_dmarc_record(["v=DMARC1; p=maybe"])
    assert result["status"] == "invalid"


def test_invalid_when_tag_repeated():
    result = validate_dmarc_record(["v=DMARC1; p=none; p=reject"])
    assert result["status"] == "invalid"


def test_invalid_when_not_dmarc1():
    result = validate_dmarc_record(["v=spf1 include:_spf.google.com ~all"])
    assert result["status"] == "invalid"
dmarc-record-missing-or-malformed.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { validateDmarcRecord } from "./dmarc-record-missing-or-malformed.js";

test("missing when no records", () => {
  const result = validateDmarcRecord([]);
  assert.equal(result.status, "missing");
});

test("duplicate when two records", () => {
  const records = ["v=DMARC1; p=none", "v=DMARC1; p=reject"];
  const result = validateDmarcRecord(records);
  assert.equal(result.status, "duplicate");
});

test("valid record with p=none", () => {
  const result = validateDmarcRecord(["v=DMARC1; p=none; rua=mailto:dmarc@example.com"]);
  assert.equal(result.status, "valid");
  assert.equal(result.tags.p, "none");
});

test("invalid when p before v", () => {
  const result = validateDmarcRecord(["p=none; v=DMARC1"]);
  assert.equal(result.status, "invalid");
});

test("invalid when p missing", () => {
  const result = validateDmarcRecord(["v=DMARC1; rua=mailto:dmarc@example.com"]);
  assert.equal(result.status, "invalid");
});

test("invalid when p value not allowed", () => {
  const result = validateDmarcRecord(["v=DMARC1; p=maybe"]);
  assert.equal(result.status, "invalid");
});

test("invalid when tag repeated", () => {
  const result = validateDmarcRecord(["v=DMARC1; p=none; p=reject"]);
  assert.equal(result.status, "invalid");
});

test("invalid when not DMARC1", () => {
  const result = validateDmarcRecord(["v=spf1 include:_spf.google.com ~all"]);
  assert.equal(result.status, "invalid");
});

Case studies

Never set up

The domain that never had DMARC at all

A small company had SPF and DKIM working fine for years and assumed that covered email authentication. It did not have DMARC, so when a scammer sent invoice fraud emails that looked like they came from the company's domain, those emails passed right through to customers, since there was no policy telling receivers what to do with a spoofed message, and no reports telling the company it was happening.

Publishing a p=none record cost nothing and broke nothing. Within two days aggregate reports showed the spoofed volume clearly, and the company moved to p=quarantine once they confirmed their own mail sources were aligned.

Typo in production

The record that looked right but had the tags swapped

A team migrated DNS to a new host and manually retyped their DMARC record. They wrote p=quarantine; v=DMARC1, tags in the wrong order, and the record sat untouched for months because nothing about it looked obviously broken in the DNS dashboard.

Running the checker script against the domain flagged it immediately as invalid, with the exact reason: p= tag must come immediately after v=DMARC1. Swapping the two tags back into the correct order and re-checking with a DMARC validator confirmed a clean pass the same day.

What good looks like

After the fix, dig +short TXT _dmarc.example.com returns exactly one string that starts with v=DMARC1; and has a valid p= value right after. A DMARC-specific checker reports a valid record with no syntax warnings. Within a day or two, aggregate reports start landing in the rua mailbox, which is the real confirmation that mail receivers are honoring the policy. Re-check whenever DNS gets migrated to a new host, since a manual retype is the most common way a good record turns back into a broken one.

FAQ

What happens if a domain has no DMARC record at all?

Mail receivers find nothing at _dmarc.yourdomain.com and treat the domain as having no DMARC policy. Spoofed mail using your domain name gets no enforcement from DMARC, and you get no reports telling you it happened. SPF and DKIM might still be set up, but without DMARC there is no policy tying them together and no visibility into failures.

Why must the p tag come right after v=DMARC1?

RFC 7489 defines v and p as the only two mandatory tags, and requires v first with p immediately after. A record that starts with v=DMARC1 but never states p, or states p before v, or misspells either tag, does not parse as a valid DMARC record, so receivers treat the domain the same as if no record existed at all.

Is it safe to start with p=none?

Yes, and it is the recommended first step. p=none tells receivers to apply no enforcement yet, but still send aggregate reports to the address in rua. Once those reports confirm SPF and DKIM are aligned and passing for your real mail, move the policy to p=quarantine and then p=reject.

Related field notes

Citations

On the problem:

  1. RFC 7489, Domain-based Message Authentication, Reporting, and Conformance (DMARC). datatracker.ietf.org/doc/html/rfc7489
  2. DMARC record tags explained, all 11 tags including p, rua, ruf, sp, and pct. dmarctrust.com
  3. What is a DMARC record: structure, tags, and examples. dmarcreport.com/dmarc-record

On the solution:

  1. Cloudflare docs: configure email security records, DMARC management. developers.cloudflare.com/dmarc-management
  2. How to add Cloudflare DMARC, SPF, and DKIM records, an easy setup guide. securityboulevard.com
  3. Cloudflare API: DNS Records endpoint. api.cloudflare.com/client/v4

Stuck on a tricky one?

If you have a DNS, domain, or email authentication 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 got your DMARC policy in place or cleared up a confusing typo, 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