Diagnostic Email Authentication

DMARC stuck at policy none indefinitely

A quick check says the domain has DMARC. There is a record, reports are arriving, everything looks fine. But look closer at the policy tag and it still says p=none, the same as the day it was first published months or years ago. That one tag means DMARC is only watching, not stopping anything. Here is why domains get stuck there and how to move forward safely.

dig and Cloudflare API Python and Node.js Fixable through the DNS API
A message icon
Photo by CHUTTERSNAP on Unsplash
The short answer

DMARC's own design treats p=none as "collect data, take no action." It was meant as a safe first step so you could see who was sending mail as your domain before you started blocking anything, not a place to stay forever. Most teams publish p=none to start getting reports, then never come back to read them, fix the senders that are not aligned, and raise the policy. The result is a domain that looks protected but provides zero anti-spoofing enforcement, indefinitely. The fix is to read the aggregate reports, confirm legitimate senders pass SPF and DKIM, and raise the same TXT record in stages to p=quarantine and then p=reject. Full commands, staged records, and a checker script are below.

The problem in plain words

DMARC has three policy settings: none, quarantine, and reject. Only the last two actually tell mailbox providers to do something about mail that fails to authenticate. The setting p=none means "watch and report, but deliver the mail anyway, pass or fail." It exists on purpose, as a way to turn on visibility without risking your own mail before you know it is safe.

The trouble is that turning it on is easy and turning it up is not. Publishing p=none takes one DNS record. Reading weeks of aggregate reports, finding every legitimate sender that is not yet aligned, and fixing each one takes real ongoing work. Many teams do the first part, tick the box, and never do the second part. The domain has a DMARC record. It has had one for a long time. It has simply never done anything to stop a spoofed email from reaching an inbox.

Spoofed email SPF and DKIM fail Receiver checks DMARC: p=none no enforcement Delivered anyway disposition: none Inbox gets spoofed mail
DMARC ran the check, saw the failure, and reported it. Because the policy is none, nothing stopped the message from landing in the inbox.

Why it happens

A quick DMARC checker tool will often say the domain is DMARC compliant, because a valid record does exist. That is a different question from whether the policy actually protects anything. Compliant and enforced are not the same thing.

The key insight

DMARC was designed as a three stage rollout, not a switch. Publishing p=none is step one, meant to last only as long as it takes to review reports and fix unaligned senders. If the policy tag has not moved in ninety days or more and reports keep showing failures, the domain is not rolling out DMARC, it is frozen at the step that protects nothing.

The fix, as a flow

Pull the record and confirm it really says p=none. Read a stretch of aggregate reports and check that every legitimate sender is passing SPF or DKIM aligned. If they are, raise the same TXT record in stages, watching reports after each stage, until it reaches p=reject.

Check p= tag dig TXT _dmarc Read aggregate reports for alignment Fix unaligned senders (SPF/DKIM) 98%+ aligned for 90 days? yes no, keep at none Raise in stages quarantine, then reject
Never raise the policy until reports show legitimate senders are aligned. Then move up one stage at a time, watching reports between each move.

How to fix it

1

Confirm the record really is stuck at p=none

Pull the TXT record directly and read the p= tag. Everything else in the record can look perfectly configured, rua reports arriving, pct=100 set, but if p=none is still there, none of that provides any enforcement.

Terminal
check (shell)
dig +short TXT _dmarc.example.com

# a record stuck at monitoring mode looks like:
# "v=DMARC1; p=none; rua=mailto:dmarc-reports@example.com; pct=100"
2

Check whether it is stuck, or just mid rollout

Look at how long rua reports have been arriving in your DMARC report tool or inbox, and check your DNS host's edit history for the record. If reports have been coming in for months and p=none has never changed, that confirms it is stuck rather than a rollout still in progress.

3

Confirm SPF and DKIM are actually aligned before moving off none

Moving off p=none before every legitimate sender is aligned can break real mail, which is often exactly why teams freeze here. Check the SPF record and look up a known DKIM selector to confirm legitimate senders pass, then confirm the same in your aggregate reports before raising anything.

Terminal
check-alignment (shell)
dig +short TXT example.com | grep spf
dig +short TXT selector._domainkey.example.com
4

Raise the same TXT record in stages, not in one jump

Update the existing record at _dmarc.example.com, same host, same name, do not create a duplicate. Move up one stage at a time, watching reports for a spike in legitimate mail failures before advancing further.

DNS record
staged records
; Stage 1: partial quarantine
_dmarc.example.com  3600  IN  TXT  "v=DMARC1; p=quarantine; pct=25; rua=mailto:dmarc-reports@example.com; ruf=mailto:dmarc-forensic@example.com"

; Stage 2: full quarantine
_dmarc.example.com  3600  IN  TXT  "v=DMARC1; p=quarantine; pct=100; rua=mailto:dmarc-reports@example.com"

; Stage 3: final, full protection
_dmarc.example.com  3600  IN  TXT  "v=DMARC1; p=reject; pct=100; rua=mailto:dmarc-reports@example.com; adkim=s; aspf=s"
5

Make the change in Cloudflare, by dashboard or by API

In the Cloudflare dashboard, go to DNS, Records, find the existing _dmarc TXT record, edit it, replace the content with the next stage's policy string, keep TTL on Auto, and save. Ramp pct from 25 to 100 over one to two weeks per stage. To do the same thing through the API, look up the record ID first, then patch its content.

Terminal
raise-policy (shell)
curl -X PATCH "https://api.cloudflare.com/client/v4/zones/{zone_id}/dns_records/{record_id}" \
  -H "Authorization: Bearer {CLOUDFLARE_API_TOKEN}" \
  -H "Content-Type: application/json" \
  --data '{"type":"TXT","name":"_dmarc.example.com","content":"v=DMARC1; p=quarantine; pct=25; rua=mailto:dmarc-reports@example.com","ttl":3600}'

How to check it worked

Re-query the record after each change and after the old TTL has expired, and confirm the change reaches an external resolver as well. Then watch the aggregate reports for the disposition on failing messages.

Terminal
verify (shell)
dig +short TXT _dmarc.example.com
dig @1.1.1.1 +short TXT _dmarc.example.com

# a good answer now shows p=quarantine or p=reject instead of p=none:
# "v=DMARC1; p=quarantine; pct=25; rua=mailto:dmarc-reports@example.com"

# within 24-48 hours, aggregate report XML should show:
# quarantine
# for failing messages, while legitimate senders keep showing pass/aligned
# with no unexpected delivery failures

The full code

Here is a small script that fetches the DMARC record for a domain, parses the tags, and flags it if the policy is still none. When told to repair, and only when the reports show it is safe, it patches the same TXT record through the Cloudflare API to bump the policy up one stage. It stays in dry run by default so it reports before it writes.

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

dmarc_policy_check.py
"""Detect a DMARC record stuck at p=none and repair it via Cloudflare.
Safe to run on a schedule. Stays in dry run until DRY_RUN=false.
"""
import os
import logging

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


def parse_dmarc_tags(record_value: str) -> dict:
    """Pure parser. No network, no I/O.

    Turns 'v=DMARC1; p=none; rua=mailto:a@b.com; pct=100' into a dict
    of tag to value.
    """
    tags = {}
    for part in record_value.split(";"):
        part = part.strip()
        if not part or "=" not in part:
            continue
        key, value = part.split("=", 1)
        tags[key.strip()] = value.strip()
    return tags


def next_dmarc_policy(record_value: str, days_since_last_change: int, spf_dkim_aligned_pct: float):
    """Pure decision function. No network, no I/O.

    Returns the next DMARC record string to publish, or None if no
    change is warranted yet.
    """
    tags = parse_dmarc_tags(record_value)

    if tags.get("p") != "none":
        return None
    if days_since_last_change < 90:
        return None
    if spf_dkim_aligned_pct < 0.98:
        return None

    tags["p"] = "quarantine"
    tags["pct"] = "25"
    order = ["v", "p", "pct", "rua", "ruf", "adkim", "aspf"]
    ordered_keys = [k for k in order if k in tags] + [k for k in tags if k not in order]
    return "; ".join(f"{k}={tags[k]}" for k in ordered_keys)


def run():
    # Imported lazily so the pure functions above can be tested with no
    # network libraries installed at all.
    import dns.resolver
    import requests

    domain = os.environ["DNS_DOMAIN"]
    days_since_last_change = int(os.environ.get("DAYS_SINCE_LAST_CHANGE", "0"))
    spf_dkim_aligned_pct = float(os.environ.get("SPF_DKIM_ALIGNED_PCT", "0"))
    dry_run = os.environ.get("DRY_RUN", "true").lower() == "true"

    zone_id = os.environ["CLOUDFLARE_ZONE_ID"]
    api_token = os.environ["CLOUDFLARE_API_TOKEN"]

    name = f"_dmarc.{domain}"
    resolver = dns.resolver.Resolver()
    try:
        answer = resolver.resolve(name, "TXT")
        records = ["".join(part.decode() if isinstance(part, bytes) else part for part in rdata.strings)
                   for rdata in answer]
    except (dns.resolver.NXDOMAIN, dns.resolver.NoAnswer):
        records = []

    if not records:
        log.warning("No DMARC record found at %s", name)
        return

    current = records[0]
    tags = parse_dmarc_tags(current)
    log.info("Current record at %s: %s", name, current)

    if tags.get("p") != "none":
        log.info("Policy is already %s, nothing to do.", tags.get("p"))
        return

    proposed = next_dmarc_policy(current, days_since_last_change, spf_dkim_aligned_pct)
    if proposed is None:
        log.info(
            "Policy is p=none but it is not safe to advance yet "
            "(days_since_last_change=%s, spf_dkim_aligned_pct=%s).",
            days_since_last_change, spf_dkim_aligned_pct,
        )
        return

    log.info("%s update %s to: %s", "Would" if dry_run else "Will", name, proposed)
    if dry_run:
        return

    lookup = requests.get(
        f"https://api.cloudflare.com/client/v4/zones/{zone_id}/dns_records",
        headers={"Authorization": f"Bearer {api_token}"},
        params={"type": "TXT", "name": name},
        timeout=30,
    )
    lookup.raise_for_status()
    existing = lookup.json().get("result", [])
    if not existing:
        log.warning("No existing TXT record found to patch for %s", name)
        return

    record_id = existing[0]["id"]
    resp = requests.patch(
        f"https://api.cloudflare.com/client/v4/zones/{zone_id}/dns_records/{record_id}",
        headers={"Authorization": f"Bearer {api_token}", "Content-Type": "application/json"},
        json={"type": "TXT", "name": name, "content": proposed},
        timeout=30,
    )
    resp.raise_for_status()
    log.info("Raised DMARC policy for %s", name)


if __name__ == "__main__":
    run()
dmarc-policy-check.js
/**
 * Detect a DMARC record stuck at p=none and repair it via Cloudflare.
 * Safe to run on a schedule. Stays in dry run until DRY_RUN=false.
 */
import { pathToFileURL } from "node:url";

export function parseDmarcTags(recordValue) {
  // Pure parser. No network, no I/O.
  const tags = {};
  for (const part of recordValue.split(";")) {
    const trimmed = part.trim();
    if (!trimmed || !trimmed.includes("=")) continue;
    const idx = trimmed.indexOf("=");
    const key = trimmed.slice(0, idx).trim();
    const value = trimmed.slice(idx + 1).trim();
    tags[key] = value;
  }
  return tags;
}

export function nextDmarcPolicy(recordValue, daysSinceLastChange, spfDkimAlignedPct) {
  // Pure decision function. No network, no I/O.
  const tags = parseDmarcTags(recordValue);

  if (tags.p !== "none") return null;
  if (daysSinceLastChange < 90) return null;
  if (spfDkimAlignedPct < 0.98) return null;

  tags.p = "quarantine";
  tags.pct = "25";
  const order = ["v", "p", "pct", "rua", "ruf", "adkim", "aspf"];
  const orderedKeys = [...order.filter((k) => k in tags), ...Object.keys(tags).filter((k) => !order.includes(k))];
  return orderedKeys.map((k) => `${k}=${tags[k]}`).join("; ");
}

export async function run() {
  // Imported lazily so the pure functions above can be tested with no
  // network modules touched at all.
  const dns = await import("node:dns");

  const domain = process.env.DNS_DOMAIN;
  const daysSinceLastChange = Number(process.env.DAYS_SINCE_LAST_CHANGE || 0);
  const spfDkimAlignedPct = Number(process.env.SPF_DKIM_ALIGNED_PCT || 0);
  const dryRun = (process.env.DRY_RUN || "true").toLowerCase() === "true";

  const zoneId = process.env.CLOUDFLARE_ZONE_ID;
  const apiToken = process.env.CLOUDFLARE_API_TOKEN;

  const name = `_dmarc.${domain}`;
  const resolver = new dns.promises.Resolver();

  let records = [];
  try {
    const answers = await resolver.resolveTxt(name);
    records = answers.map((chunks) => chunks.join(""));
  } catch (err) {
    if (err.code !== "ENOTFOUND" && err.code !== "ENODATA") throw err;
  }

  if (records.length === 0) {
    console.warn(`No DMARC record found at ${name}`);
    return;
  }

  const current = records[0];
  const tags = parseDmarcTags(current);
  console.log(`Current record at ${name}: ${current}`);

  if (tags.p !== "none") {
    console.log(`Policy is already ${tags.p}, nothing to do.`);
    return;
  }

  const proposed = nextDmarcPolicy(current, daysSinceLastChange, spfDkimAlignedPct);
  if (proposed === null) {
    console.log(
      `Policy is p=none but it is not safe to advance yet ` +
      `(daysSinceLastChange=${daysSinceLastChange}, spfDkimAlignedPct=${spfDkimAlignedPct}).`
    );
    return;
  }

  console.log(`${dryRun ? "Would" : "Will"} update ${name} to: ${proposed}`);
  if (dryRun) return;

  const lookupRes = await fetch(
    `https://api.cloudflare.com/client/v4/zones/${zoneId}/dns_records?type=TXT&name=${encodeURIComponent(name)}`,
    { headers: { Authorization: `Bearer ${apiToken}` } },
  );
  if (!lookupRes.ok) throw new Error(`Cloudflare lookup returned ${lookupRes.status}`);
  const lookupBody = await lookupRes.json();
  const existing = lookupBody.result || [];
  if (existing.length === 0) {
    console.warn(`No existing TXT record found to patch for ${name}`);
    return;
  }

  const recordId = existing[0].id;
  const res = await fetch(
    `https://api.cloudflare.com/client/v4/zones/${zoneId}/dns_records/${recordId}`,
    {
      method: "PATCH",
      headers: { Authorization: `Bearer ${apiToken}`, "Content-Type": "application/json" },
      body: JSON.stringify({ type: "TXT", name, content: proposed }),
    },
  );
  if (!res.ok) throw new Error(`Cloudflare API returned ${res.status}`);
  console.log(`Raised DMARC policy for ${name}`);
}

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 decision function is the part worth testing on its own, because it decides whether a live production policy gets raised. It takes a plain string and two numbers, so the test needs no network and no DNS library at all.

test_dmarc_policy.py
from dmarc_policy_check import next_dmarc_policy

RECORD = "v=DMARC1; p=none; rua=mailto:dmarc-reports@example.com; pct=100"


def test_none_when_already_past_none():
    record = "v=DMARC1; p=quarantine; pct=25; rua=mailto:dmarc-reports@example.com"
    assert next_dmarc_policy(record, 200, 0.99) is None


def test_none_when_too_soon_since_last_change():
    assert next_dmarc_policy(RECORD, 30, 0.99) is None


def test_none_when_alignment_too_low():
    assert next_dmarc_policy(RECORD, 200, 0.80) is None


def test_bumps_to_quarantine_when_safe():
    result = next_dmarc_policy(RECORD, 200, 0.99)
    assert "p=quarantine" in result
    assert "pct=25" in result
dmarc-policy-check.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { nextDmarcPolicy } from "./dmarc-policy-check.js";

const RECORD = "v=DMARC1; p=none; rua=mailto:dmarc-reports@example.com; pct=100";

test("null when already past none", () => {
  const record = "v=DMARC1; p=quarantine; pct=25; rua=mailto:dmarc-reports@example.com";
  assert.equal(nextDmarcPolicy(record, 200, 0.99), null);
});

test("null when too soon since last change", () => {
  assert.equal(nextDmarcPolicy(RECORD, 30, 0.99), null);
});

test("null when alignment too low", () => {
  assert.equal(nextDmarcPolicy(RECORD, 200, 0.80), null);
});

test("bumps to quarantine when safe", () => {
  const result = nextDmarcPolicy(RECORD, 200, 0.99);
  assert.match(result, /p=quarantine/);
  assert.match(result, /pct=25/);
});

Case studies

Set and forgotten

The record nobody touched for three years

A company published p=none when a security consultant recommended it during a one time audit. The engagement ended, the recommendation was marked done, and the record never changed again. Aggregate reports piled up unread in a shared inbox that eventually hit its storage limit and started bouncing them.

A phishing campaign later spoofed the domain and reached real inboxes with no friction at all. Checking the record with dig took ten seconds and confirmed it was still p=none. Reviewing three months of reports showed every real sender aligned, and the team moved straight into staged quarantine that week.

Broken rollout, no retry

The quarantine attempt that got rolled back and never revisited

A team tried moving to p=quarantine once, and a legacy helpdesk tool that sent on behalf of the domain was not SPF or DKIM aligned. Some support notifications landed in spam. The team rolled the policy back to p=none the same day to stop the complaints, and it was never brought up again.

Months later, a checker script flagged the same domain still sitting at p=none. This time the helpdesk tool was fixed to send through the domain's own SPF include, reports confirmed it was aligned, and the staged rollout to quarantine and then reject completed without breaking anything.

What good looks like

Once the policy is raised, dig +short TXT _dmarc.example.com shows p=quarantine or p=reject instead of p=none, and aggregate reports show a disposition of quarantine or reject for failing messages while every legitimate sender keeps passing. Keep reading the reports after that. DMARC is a policy you maintain, not a record you set once.

FAQ

Why does my domain look DMARC enabled but still let spoofed mail through?

A DMARC record with p=none tells receivers to take no action and just send reports. It was designed as a monitoring first step, not a finish line. If the policy tag was never raised to quarantine or reject, spoofed mail that fails SPF and DKIM is still delivered normally, even though a DMARC record is technically published.

How do I know if my domain is stuck at p=none instead of mid rollout?

Pull the record with dig and read the p= tag. If it says p=none and your DMARC report inbox has been receiving aggregate reports for weeks or months with no change to that tag, and your DNS host's edit history shows no recent update to the record, that is stuck, not mid rollout.

Is it safe to jump straight from p=none to p=reject?

Only if your aggregate reports already show all legitimate senders passing SPF or DKIM aligned. If they are not aligned yet, jumping straight to reject can block real mail. Raise the policy in stages, quarantine at a low percentage first, then quarantine at full percentage, then reject, checking reports between each stage.

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 p=none: why monitoring mode does not block spoofs. blueredix.com/en/knowledge/dmarc-monitor-only
  3. DMARC policy guide: the complete journey from none to quarantine to reject. dmarcreport.com/blog/dmarc-policy-none-quarantine-reject-guide

On the solution:

  1. Cloudflare docs: configure email security records, DMARC management. developers.cloudflare.com/dmarc-management/security-records
  2. Cloudflare Learning Center: what is a DNS DMARC record. cloudflare.com/learning/dns/dns-records/dns-dmarc-record
  3. Cloudflare Community: how to fix DMARC quarantine or reject policy not enabled. community.cloudflare.com/t/how-to-fix-dmarc-quarantine-reject-policy-not-enabled/799825

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 DMARC rollout?

If this saved you from staying stuck at monitoring mode for another year, 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