DEV Community

Cover image for How to Read DMARC Reports Before You Move to p=reject
Pro Gineous
Pro Gineous

Posted on Originally published at blog.progineous.com

How to Read DMARC Reports Before You Move to p=reject

Most domains that have DMARC at all are sitting on p=none. Someone added the record while setting up email, pointed rua= at a mailbox, and the reports have been arriving every day since: small zip and gzip attachments from Google, Outlook and other receivers, usually unopened.

Those files answer the one question you need settled before tightening the policy: who is sending mail with your domain in the From line, and would that mail survive p=quarantine or p=reject? Below is how to read them with nothing more than dig, unzip and a small XML tool, and how to move up the policy ladder without losing invoices or password resets on the way. If SPF and DKIM aren't set up yet, start with how to set up business email on your own domain and come back once those records exist.

First, confirm what you've published

Reports describe how receivers judged your mail against your DNS records, so read those records the way a receiver would. Three lookups cover it:

dig +short TXT _dmarc.example.com
dig +short TXT example.com
dig +short TXT s1._domainkey.example.com
Enter fullscreen mode Exit fullscreen mode

Terminal screenshot of three dig lookups: the DMARC record at _dmarc.example.com showing v=DMARC1 with p=none and a rua reporting address, the SPF record for example.com ending in -all next to a site verification TXT record, and a 2048-bit DKIM public key for selector s1 split into two quoted strings

The DMARC record lives at the _dmarc label, not on the domain itself. p=none means "report, but don't act", and rua= is where aggregate reports go. If that address is on a different domain, such as a reporting service, the other domain has to publish a small authorization record (example.com._report._dmarc.reports.example.net with the value v=DMARC1). Without it, receivers quietly skip sending.

On the domain itself, look for exactly one TXT record starting with v=spf1. Two of them make SPF return a permanent error, which counts as a fail for DMARC. Other TXT records, like site verification strings, can sit alongside it.

DKIM is the awkward one, because there's no way to list a domain's keys. You need the selector, which is the s= tag in the DKIM-Signature header of any message you've sent (use "show original" or "view source" in your mail client). Your mailbox provider uses one selector, and every other service that signs as your domain uses its own. A 2048-bit key is longer than the 255-character limit for a single TXT string, so dig prints it as two quoted chunks. That's normal. Receivers join them back together.

Open one report

Reports arrive as attachments named after the receiver, your domain and the time window they cover:

google.com!example.com!1792281600!1792367999.zip
enterprise.protection.outlook.com!example.com!1792281600!1792368000.xml.gz
Enter fullscreen mode Exit fullscreen mode

The two long numbers are the start and end of the window in Unix time. date -u -d @1792281600 turns the first one into midnight UTC on October 18. Some receivers zip the file and others gzip it, so unzip or gunzip gets you to the XML. After a short header naming the reporter and repeating the policy it found, there's one <record> per combination of sending IP and result. This one deserves a close look:

<record>
  <row>
    <source_ip>198.51.100.24</source_ip>
    <count>212</count>
    <policy_evaluated>
      <disposition>none</disposition>
      <dkim>fail</dkim>
      <spf>fail</spf>
    </policy_evaluated>
  </row>
  <identifiers>
    <header_from>example.com</header_from>
  </identifiers>
  <auth_results>
    <dkim>
      <domain>mailer.example.net</domain>
      <result>pass</result>
      <selector>m1</selector>
    </dkim>
    <spf>
      <domain>bounces.mailer.example.net</domain>
      <result>pass</result>
    </spf>
  </auth_results>
</record>
Enter fullscreen mode Exit fullscreen mode

Read it in this order:

  • source_ip and count: 212 messages from one IP, all showing example.com in the From header (header_from).
  • auth_results: the raw checks. DKIM passed and so did SPF. On their own, they look fine.
  • policy_evaluated: DMARC's verdict, and both say fail. DMARC only counts a pass when the domain that passed matches the From domain. DKIM was signed by mailer.example.net and SPF checked bounces.mailer.example.net. Neither is example.com, so neither is aligned.
  • disposition: what the receiver actually did. none, because that's what the policy asked for. Under p=reject, those 212 messages would have bounced.

Passing in auth_results but failing in policy_evaluated is the pattern you'll see most, and it's nearly always a legitimate service you forgot about, not an attacker. Reports that follow the updated aggregate reporting spec (RFC 9990) may add an xmlns attribute and a few extra fields, but the record layout is the same.

Turn a folder of reports into a sender list

One report is readable by eye. A month of them from a dozen receivers isn't, so pull out the useful columns with xmlstarlet (apt install xmlstarlet on Debian and Ubuntu, brew install xmlstarlet on a Mac):

unzip 'google.com!example.com!1792281600!1792367999.zip'
gunzip 'enterprise.protection.outlook.com!example.com!1792281600!1792368000.xml.gz'
xmlstarlet sel -t -m //record -v row/source_ip -o ' ' -v row/count \
  -o ' dkim=' -v row/policy_evaluated/dkim -o ' spf=' -v row/policy_evaluated/spf \
  -o ' signer=' -v auth_results/dkim/domain -n *.xml | sort -k2 -rn
Enter fullscreen mode Exit fullscreen mode

Then ask DNS who each unfamiliar IP belongs to with a reverse lookup, for example dig +short -x 198.51.100.24.

Terminal screenshot of extracting DMARC aggregate reports with unzip and gunzip, then an xmlstarlet one-liner listing each sending IP with its message count, aligned DKIM and SPF results and DKIM signer, followed by reverse DNS lookups that identify a newsletter platform, the company web server and an IP with no reverse DNS

Every row lands in one of four buckets, and each bucket has its own fix:

  1. Your mailbox provider (192.0.2.25 here). DKIM and SPF are both aligned. Nothing to do.
  2. A service sending on your behalf (198.51.100.24, which reverse-resolves to a newsletter platform). It passes, but for the wrong domain. Most of these services have a "domain authentication" or "custom sending domain" setting that hands you one or two CNAME records, such as m1._domainkey.example.com pointing at their key. Once they sign with d=example.com, DKIM aligns and DMARC passes. Aligning SPF too, with a custom return-path on a subdomain, is nice to have but not required. One aligned pass is enough.
  3. Your own servers (203.0.113.80, the web server). A contact form or shop sending straight from PHP, with no DKIM signature and an IP that isn't in SPF. Resist the urge to just add the IP to SPF. Send that mail through your mailbox provider's SMTP submission port with a real account instead, so it gets the same aligned signature as everything else.
  4. Nobody you recognize (203.0.113.199, no reverse DNS, nine messages). That's someone using your domain without permission, and it's exactly the mail p=reject exists to stop. Leave it failing.

Expect a fuzzy fifth group as well: forwarded mail and mailing lists. A list that adds a footer breaks the DKIM signature, and a forwarder's IP was never in your SPF, so a small trickle of failures from university or ISP ranges is normal and can't be fixed from your side. Some large receivers use ARC to give that mail the benefit of the doubt. Don't chase it.

When the volume outgrows a shell one-liner, open-source parsers like parsedmarc can load reports into a dashboard, and hosted report services do the same job. The four buckets stay the same.

Tighten in steps, not in one edit

Flat diagram of a safe DMARC rollout in three stages, from p=none for monitoring to p=quarantine to p=reject, with the check to pass before each step: every legitimate sender aligned in the reports, nothing legitimate quarantined, and a lowered TTL on the _dmarc record so the change can be rolled back

Once every legitimate source in the last few weeks of reports is aligned, raise the policy one level at a time:

v=DMARC1; p=quarantine; rua=mailto:dmarc-reports@example.com
v=DMARC1; p=reject; sp=reject; rua=mailto:dmarc-reports@example.com
Enter fullscreen mode Exit fullscreen mode

Older guides ramp up with pct=10, then pct=25, and so on. RFC 9989, the DMARC standard published in May 2026, retires pct because receivers applied the in-between values inconsistently. Its replacement is t=y, which asks receivers to apply one level below what you published, so p=reject; t=y behaves like quarantine at receivers that support it. Plain steps work everywhere: stay on quarantine for a couple of weeks, confirm that nothing legitimate shows up with a quarantine disposition, then move to reject.

A few habits keep each change uneventful:

  • Keep rua= for good. Reports are how you'll spot the next forgotten sender, like a helpdesk or billing tool someone signs up for next year.
  • Lower the TTL on the _dmarc record to 300 or 600 seconds before each change, so stepping back takes minutes rather than hours.
  • Decide on subdomains deliberately. sp= sets the policy for existing subdomains, which otherwise inherit p. Receivers that follow RFC 9989 also read np=reject, which covers subdomains that don't exist at all.
  • Lock down domains that never send mail. A parked domain can publish v=spf1 -all and v=DMARC1; p=reject on day one. There's nothing to monitor.

After each step, send a test message from every service in bucket two and check that the Authentication-Results header on the received copy shows dmarc=pass.

What reject actually buys you

At p=reject, receivers that honor DMARC refuse mail that claims to be from your domain and can't prove it. Phishing that copies your exact address stops reaching customers' inboxes, and your own mail stops sharing a reputation with whoever was spoofing it. Look-alike domains are a separate problem, and DMARC doesn't cover them.

None of this needs special software. A few weeks of reading the reports, fixing the senders you recognize, and reject becomes a short DNS edit instead of a gamble. If you're moving mailboxes as part of the cleanup, Pro Gineous business email gives each address a mailbox on your own domain, and every check above works the same there as with any other provider.

Related reading

Originally published on the Pro Gineous blog.

Top comments (0)