Reading DMARC Reports: Forwarding, Spoofing, and When Not to Set p=reject
Most DMARC failures in a healthy domain are forwarding, not attacks. Here's how to tell them apart from the report itself, and why the reporting organisation matters more than the source IP.
You turn on DMARC reporting, the first aggregate reports arrive, and a chunk of your mail is failing. The instinct is that somebody is spoofing you.
Usually they aren't. In a domain that is otherwise configured correctly, most failures are forwarding: a legitimate message you sent, delivered successfully, then relayed onward by the recipient's own infrastructure in a way that breaks the authentication on the second hop. It looks identical to an attack in a summary view, and the difference decides whether p=reject is safe or whether it quietly breaks a client's mail.
This is how we read those reports, including the one diagnostic that does most of the work and gets left out of nearly every guide.
First, rule out the boring explanation
Before reading a single report, confirm your own SPF actually authorises whatever sends your mail. This is the most common root cause and it is embarrassing in a specific way, because everything looks configured.
The failure we see most often is an SPF record inherited from the registrar rather than the mail provider. A domain whose MX points at Google Workspace, publishing v=spf1 include:_spf.porkbun.com ~all because that was set up years ago for registrar forwarding. Google is sending, the record authorises somebody else, and SPF fails on every message you send. Fixing that one record moved a domain we run from 1.2 to 8.4 on mail-tester, and roughly 8.3 of the 8.8 lost points traced to it alone.
Two things worth knowing while you are in there. _spf.google.com is flat now, so it costs one of your ten DNS lookups rather than four. And a registrar include can be expensive: the Porkbun one carries six a: mechanisms, so keeping it alongside anything else risks a PermError, which fails SPF as surely as having no record.
Also check DKIM properly. The v=DKIM1; prefix is optional, and some providers omit it. Resend publishes a selector that starts straight at p=, so grepping DNS for DKIM1 returns a false negative on a record that is present and working. Match on DKIM1 or p=.
What a forwarded failure actually looks like
Once your own sending is clean, the failures left in the report are other people's infrastructure.
The signal that separates forwarding from spoofing is the state of your own DKIM signature, and it is unambiguous:
- A spoofer produces no aligned signature at all. They cannot sign as your domain, because they do not have your key. The report shows your domain in the header From, and nothing aligned.
- A forwarded message shows your signature present and failing. That can only happen if the message genuinely left your infrastructure carrying a valid signature, and something modified it in transit.
That second case is not an attack. It is a mail server doing its job badly, and there is a specific culprit: gateways that rewrite links for security scanning. Rewriting a URL in the body changes the body, which invalidates the DKIM body hash, which fails the signature. The security product breaks the authentication of the mail it is protecting.
SPF fails on forwarding for a simpler reason: the forwarding server is now the sender, and it is not in your SPF record. That is expected and is why DMARC accepts either SPF or DKIM alignment rather than both.
The diagnostic that identifies the forwarder
Here is the part most guides skip, and it is the one that turns "something is failing" into a name.
When you break down failures, break them down by reporting organisation, not just source IP. The source IP tells you which server relayed the message. The reporting organisation tells you where the forwarded copy landed, and that is what identifies the forwarder, because a forwarder relays into a specific destination.
A worked example from our own domain, from a window of aggregate reports covering nine days:
- 18 failures from a Microsoft 365 tenant, reported by Google. So that tenant forwards into a Google mailbox.
- 17 failures from a host called
shield.security, reported by Enterprise Outlook. So Shield relays into a Microsoft 365 tenant.
Two failure sources, and the reporter on each pointed the opposite way. That crossing pattern is what let us reconstruct the path. We correspond with exactly one M365 domain, the Shield failures clustered on precisely the days we emailed them, and a PTR lookup on the new IP returned smtp005.production.us-east-2.shield.security. Mailprotector Shield is an MSP-sold secure email gateway that fronts Microsoft 365 and rewrites links.
So the chain was: our mail delivered successfully to the client, and their own MSP-managed gateway then redistributed it internally, breaking DKIM on the way. Not spoofing, not our problem to fix, and critically not something that would stop happening.
Without the reporter breakdown, that is two anonymous IPs failing and no way to tell whose they are.
Why that changes the p=reject decision
The temptation once your own sending is verifiably clean is to go straight to p=reject. Both of our real senders were passing with alignment, so the record looked ready.
It was not, and the reason is the forwarding above. The original delivery to the client is in the pass bucket. p=reject would not have broken our mail to the people we were actually writing to. It would have broken the internal redistribution copies inside their organisation, which land in whatever shared or case-management inbox that gateway feeds.
That is a horrible way to find out, especially with an open proposal at the client. Nobody would have reported it as a bounce, because the person we emailed received the message fine.
Two conclusions came out of that.
Gate the ramp on volume, not the calendar. "Run p=none for a month" is the standard advice and it is wrong at low volume. At one to four reported messages a day, a month yields about forty records, which proves nothing about a sender that only fires quarterly. Our rule: do not advance past p=none until the parser shows 300+ cumulative reported messages, every legitimate source is at 100% pass, and no new legitimate sender has appeared in the failure bucket. Stages are p=quarantine; pct=25, then pct=100, then p=reject. Every stage is a DNS edit, so none of them are one-way.
Consider two domains with different policies. A root domain that carries client correspondence will keep collecting forwarding failures indefinitely, because you cannot fix somebody else's gateway. A dedicated campaign domain has no forwarding legacy and can reach p=reject quickly. Wanting a tidy p=reject on the root is not worth risking client deliverability for.
There is also no compliance clock pushing you. The Gmail and Yahoo bulk-sender requirements ask for p=none as a minimum, so a domain sitting at p=none with clean alignment is already compliant.
Set sp=reject even while p=none
One record detail worth acting on immediately, because it costs nothing and closes the most likely attack.
p=none; sp=reject protects your subdomains at full strength while the root policy is still permissive. Attackers spoof subdomains that do not exist precisely because those have no forwarding history to hide behind, and you have no legitimate mail there to break.
Watch for vendor templates here. A DMARC parser we set up supplied a suggested record carrying sp=none, which would have silently dropped subdomain protection, and listed only their own rua. Vendor templates assume a domain with no existing configuration. Merge them into what you have; never paste over it.
The other landmine is structural: two TXT records at _dmarc means receivers treat the domain as having no policy at all and ignore both. Always edit the existing record in place. In most registrar UIs the host field is _dmarc, not the fully-qualified name, and getting that wrong is how you end up with two.
While you are in the records: MTA-STS
Different mechanism, same afternoon, and it has deployment traps that cost real time.
MTA-STS tells sending servers to require TLS to your domain. The policy cannot live in DNS. The _mta-sts TXT record only carries a version id, which is a cache key. The policy itself is served over HTTPS at a hardcoded path, mta-sts.yourdomain.com/.well-known/mta-sts.txt, and that is the entire security model: DNS is spoofable, the TLS PKI is not.
Three things that will waste your morning:
The policy host must serve, never redirect. RFC 8461 forbids following redirects on the policy fetch. If you have an apex-to-www redirect, it applies to /.well-known/ paths too, so the subdomain has to be attached as a serving domain in its own right rather than pointed at the main site.
A wildcard DNS record makes everything resolve. Plenty of registrars publish * pointing at a parking host. So the hostname resolving proves nothing about whether your TXT record exists. Check the TXT specifically.
Switching from testing to enforce needs two changes. Edit the policy file to mode: enforce and bump the id in the _mta-sts TXT record. Senders cache on that id, so changing only the file changes nothing for anyone who has already fetched it.
Pair it with TLS-RPT and you get reports on whether TLS negotiation is actually succeeding. One caveat on reading those: reports come from external sending MTAs, so if you are on Google Workspace, Google-to-Google traffic is intra-provider and Google will likely never report on it. Microsoft may be your only reporter. Waiting for a second independent one can mean waiting for something that will not arrive.
The order that works
- Fix SPF so it authorises whatever actually sends your mail, and confirm DKIM by matching on
DKIM1orp=. - Publish
p=none; sp=rejectwithruapointed at a parser, andfo=1so you get failure detail. - Wait for volume, not weeks.
- Break failures down by reporting organisation, not just source IP, and identify every sender before touching the policy.
- Advance through quarantine in stages, keeping
sp=rejectthroughout. - Do MTA-STS and TLS-RPT separately, once the DMARC picture is stable.
Most of the difficulty is not in the records. It is in reading the reports without assuming that a failure means an attack, and in noticing that some of the failures belong to infrastructure you will never control.
If you would rather have the audit done than do it, the email deliverability engagement is exactly this: the records, a parser, the sender inventory, and a policy ramp with a decision rule attached. And if you are standing up cold outbound, do it on a separate domain from the one carrying your client mail, for the reason in the section above.