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.
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.
Why it happens
- Nobody ever set up DMARC in the first place. SPF and DKIM might be configured, but DMARC is a separate record that has to be added on its own, and it is easy to skip.
- A typo in the TXT record value, such as an extra space, a missing semicolon between tags, or the
p=tag left out entirely, breaks the grammar the RFC requires even though the record looks fine at a glance. - The tags are in the wrong order, for example
p=none; v=DMARC1instead ofv=DMARC1; p=none. RFC 7489 requiresvfirst andpimmediately after, so swapping them invalidates the record. - Two TXT records end up published at
_dmarcat the same time, often left over from a migration or added twice by two different tools. A duplicate at that name is invalid, the same way duplicate SPF records are invalid at the apex. - A leftover domain verification token or some other unrelated TXT string sits at the exact
_dmarcname, so the string that is found does not even start withv=DMARC1.
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.
How to fix it
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.
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
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.
# 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"
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.
# 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.
# 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.
"""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()
/**
* 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.
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"
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
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.
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.
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:
- RFC 7489, Domain-based Message Authentication, Reporting, and Conformance (DMARC). datatracker.ietf.org/doc/html/rfc7489
- DMARC record tags explained, all 11 tags including p, rua, ruf, sp, and pct. dmarctrust.com
- What is a DMARC record: structure, tags, and examples. dmarcreport.com/dmarc-record
On the solution:
- Cloudflare docs: configure email security records, DMARC management. developers.cloudflare.com/dmarc-management
- How to add Cloudflare DMARC, SPF, and DKIM records, an easy setup guide. securityboulevard.com
- 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.
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