DMARC: the complete technical guide

DMARC is the least understood of the three email-authentication standards. SPF and DKIM are each a mechanism; DMARC is the policy layer that ties them to your domain and tells receiving servers what to do when they disagree.

The three acronyms

All three are published as DNS records on the domain you send from, and all three are checked by the receiving server, not by yours.

SPF — Sender Policy Framework. Lists the servers allowed to send mail for your domain. The receiver checks the IP address that connected against that list.

DKIM — DomainKeys Identified Mail. Signs each message with a private key whose public half is published in your DNS. Proves the message was authorised by the signing domain and has not been altered since.

DMARC — Domain-based Message Authentication, Reporting, and Conformance. Ties the other two to the From address a human actually reads, states what receivers should do when neither backs it up, and asks them to report what they saw.

The last two words of DMARC’s name are the two things it adds. Conformance is the policy — none, quarantine, reject — and it is worth nothing without alignment, the rule the next two sections are about. Reporting is the part people skip and then regret: without it you are guessing at which of your own senders you are about to break.

This guide takes those one-line summaries as its starting point and gets into how SPF and DKIM actually interact under DMARC, what every record tag does, and the specific ways real-world mail breaks when you get it wrong.

What DMARC actually protects

DMARC protects your domain from being spoofed in the From header. It does not encrypt mail, protect privacy, or stop inbound spam. A receiving server that implements DMARC checks two things about every message that claims to be from your domain:

  1. Does it pass SPF or DKIM?
  2. Does whichever one passed align with the domain in From?

Only if both questions are answered yes does the message pass DMARC. If it fails, the receiver applies the policy you published.

The alignment problem

This is the part most people get wrong. SPF and DKIM passing is not enough — they must align with the From domain.

  • SPF authenticates the envelope sender (RFC5321.MailFrom, the Return-Path). A message can pass SPF for one domain while its From header says a completely different domain.
  • DKIM authenticates via a header signature with a d= domain. The d= domain is whatever domain signed the message — again, not necessarily the From domain.

Alignment is the rule that the From domain must match the domain that actually passed. Without alignment, an attacker could pass SPF with their own domain and forge your From header.

DMARC defines two alignment modes, set independently for SPF and DKIM:

  • Relaxed (default): the organizational domain must match. mail.example.com aligns with example.com.
  • Strict: the domains must match exactly. mail.example.com does not align with example.com.

Alignment is the entire reason DMARC exists. SPF and DKIM alone have been around since the early 2000s; spoofing kept working because nothing forced the From domain to match either of them.

The record

DMARC is published as a TXT record at _dmarc.example.com. A full, working record looks like this:

_dmarc.example.com  TXT  "v=DMARC1; p=reject; rua=mailto:dmarc@example.com; adkim=s; aspf=s; sp=reject; fo=1"

Every tag, in order of importance:

TagMeaningNotes
v=DMARC1Version, must be firstLiterally always DMARC1
p=Policy: none, quarantine, or rejectRequired. The whole point
sp=Subdomain policyDefaults to p= if omitted
adkim=DKIM alignment: r or sr = relaxed (default)
aspf=SPF alignment: r or sr = relaxed (default)
rua=Aggregate report addressmailto: required, comma-separated
ruf=Forensic report addressOften ignored by receivers
fo=Forensic reporting options: 0, 1, d, sOnly matters if ruf= is set
pct=Percentage of mail the policy applies toWidely ignored; avoid relying on it
ri=Report interval in secondsDefault 86400 (daily)

The three policies

p=none — monitoring only. Receivers send you reports but deliver mail normally regardless of DMARC result. This is where you start.

p=quarantine — failing mail goes to spam/junk instead of the inbox. Receivers interpret “quarantine” loosely; some treat it as reject.

p=reject — failing mail is bounced outright. This is the end state for a domain you fully control and have tested.

The correct progression is nonequarantinereject, and each step should last long enough to read the aggregate reports and confirm legitimate mail isn’t failing. Jumping straight to reject is the single most common way people break their own outbound mail.

How evaluation works, step by step

When a receiver gets a message claiming to be from example.com:

  1. Run SPF: does the connecting IP match the sender’s SPF record? If yes, note the envelope domain.
  2. Run DKIM: does the DKIM-Signature validate? If yes, note the d= domain.
  3. Check SPF alignment: does the envelope domain align with the From domain (under your aspf mode)?
  4. Check DKIM alignment: does the d= domain align with From (under adkim)?
  5. DMARC passes if SPF passes AND aligns, OR DKIM passes AND aligns.

Either mechanism is sufficient. You don’t need both — but you do need at least one to both pass and align. The two checks are independent, and each one has to clear the same second gate:

Path A — SPF
  1. Does the connecting IP match the SPF record?
  2. Does the envelope domain align with From?

Breaks on forwarding: the server that connects is the forwarder, not yours, and it will not be in your record.

Path B — DKIM
  1. Does the DKIM-Signature validate?
  2. Does the d= domain align with From?

Survives forwarding, because the signature travels with the message. Breaks if anything modifies what was signed.

Either column passing end to end is enough, and this is the whole of DMARC's logic. Both columns failing is what your p= policy is for — and the report in the next section shows Path A failing while Path B carries the message through.

What a report actually looks like

That is the theory. Here is one day of it, for this site’s own domain.

Aggregate reports arrive as gzipped XML, one per receiver per day, to whatever address you put in rua=. Nobody reads them by hand at volume — but reading one by hand, once, is the fastest way to understand what the reports are telling you. This is a real report from Google for cripta.to, in full, split into its three parts.

Part one: who is reporting, and what they think your policy is

<report_metadata>
  <org_name>google.com</org_name>
  <email>noreply-dmarc-support@google.com</email>
  <report_id>7607524531478255721</report_id>
  <date_range>
    <begin>1786752000</begin>
    <end>1786838399</end>
  </date_range>
</report_metadata>
<policy_published>
  <domain>cripta.to</domain>
  <adkim>r</adkim>
  <aspf>r</aspf>
  <p>quarantine</p>
  <sp>quarantine</sp>
  <pct>100</pct>
  <np>quarantine</np>
</policy_published>

date_range is in Unix seconds, which catches everyone the first time — date -r 1786752000 on macOS, date -d @1786752000 on Linux. It is one UTC day.

policy_published is the more useful half, and it is the part people skim past: it is your DMARC record as the receiver actually parsed it. If you published p=reject and this says quarantine, your DNS change has not propagated, or you have a second record shadowing it, or a tag is malformed and was dropped. It is the only feedback loop that tells you what the world sees, rather than what you think you published. Note np= too — the policy for subdomains that do not exist at all, a later addition to the standard that Google echoes back whether or not you set it.

Part two: a message that passed

<record>
  <row>
    <source_ip>46.224.237.87</source_ip>
    <count>1</count>
    <policy_evaluated>
      <disposition>none</disposition>
      <dkim>pass</dkim>
      <spf>pass</spf>
    </policy_evaluated>
  </row>
  <identifiers>
    <header_from>cripta.to</header_from>
  </identifiers>
  <auth_results>
    <dkim>
      <domain>cripta.to</domain>
      <result>pass</result>
      <selector>v1-rsa-20260517</selector>
    </dkim>
    <spf>
      <domain>cripta.to</domain>
      <result>pass</result>
    </spf>
  </auth_results>
</record>

Each <record> is a group of messages that shared a fate, not a single message — count is how many. source_ip is the server that connected to Google.

This one is the boring case, which is the case you want: our own mail server, both mechanisms passing, both aligned with the From domain. disposition: none means Google did nothing to the message, because there was nothing to do.

Part three: a message where SPF failed, and it was delivered anyway

<record>
  <row>
    <source_ip>2a00:1450:4864:20::348</source_ip>
    <count>1</count>
    <policy_evaluated>
      <disposition>none</disposition>
      <dkim>pass</dkim>
      <spf>fail</spf>
    </policy_evaluated>
  </row>
  <identifiers>
    <header_from>cripta.to</header_from>
  </identifiers>
  <auth_results>
    <dkim>
      <domain>cripta.to</domain>
      <result>pass</result>
      <selector>v1-rsa-20260517</selector>
    </dkim>
    <spf>
      <domain>cripta.to</domain>
      <result>softfail</result>
    </spf>
  </auth_results>
</record>

This is the record worth understanding, and it demonstrates three things the rest of this guide only asserts.

The source is not our server. 2a00:1450:… is Google. Something sent, or more likely forwarded, a message carrying our From address through Google’s infrastructure. That IP is not in our SPF record and never will be.

SPF failed and the message was delivered. DKIM passed and aligned, which is sufficient on its own — the signature travelled with the message and survived the trip, exactly as the “forwarding breaks SPF” section below describes. disposition: none under a published policy of quarantine is DMARC working, not DMARC being lenient. Had we been relying on SPF alone, this message would have been quarantined.

policy_evaluated and auth_results disagree on purpose. auth_results reports the raw SPF result — softfail, because our SPF record ends in ~all. policy_evaluated reports the result after DMARC alignment, and there it is plain fail. That is the “softfail is invisible to DMARC” gotcha below, visible in a real file: the distinction you tuned so carefully is flattened to pass-or-fail one element up.

What to do with them

The question a report answers is not “am I being spoofed”. It is “which of my own senders would break if I tightened the policy”. Work down the records:

  • A source you recognise, everything passing. Nothing to do.
  • A source you recognise, one mechanism failing. Fix that mechanism before you tighten. This is the CRM you forgot to add to SPF, or the marketing platform that is not signing with your domain.
  • A source you do not recognise, DKIM passing. Almost always a forwarder. Harmless, and the reason DKIM matters more than SPF.
  • A source you do not recognise, everything failing. Either a sender you have forgotten about — which is the common case, and the reason to look before tightening — or someone spoofing you, which is what the policy is for.

When every source you recognise passes, and has for a few weeks, you can move to the next policy. That is the entire loop. Everything else in the tooling is a nicer rendering of these two questions.

The gotchas

These are the failure modes that actually bite people in production.

SPF’s 10-lookup limit

SPF is limited to 10 DNS lookups per message (RFC 7208). Every include:, a, mx, ptr, and exists mechanism counts, as does redirect=. Nested include: chains count too — a record that includes a record that includes three more is four lookups. ip4: and ip6: are free, which is what makes flattening work.

Exceeding the limit returns permerror, which receivers treat as SPF failure. The symptom is confusing: SPF “passes” in every online validator but fails for real mail. If you use many SaaS senders (CRM, helpdesk, marketing), you will hit this ceiling, and the only fixes are flattening your SPF record (inlining the included IPs) or moving senders to a subdomain with its own SPF.

Forwarding breaks SPF

SPF authenticates the connecting server. When a message is forwarded — by a mailing list, a .forward, or a forwarder like a university alumni address — the final delivery server is the forwarder, not your authorized server. The forwarder is not in your SPF record, so SPF fails.

Proper forwarders use SRS (Sender Rewriting Scheme) to rewrite the envelope sender to their own domain, which fixes SPF but changes the Return-Path. Naive forwarders don’t, and their forwarded mail fails SPF at the destination.

DKIM is the fix here, because a DKIM signature survives forwarding as long as the forwarder doesn’t modify the signed headers or body.

DKIM breaks when the message is modified

A DKIM signature covers specific headers (h=) and a hash of the body. Anything that changes those — a mailing list appending a footer, a server rewriting the subject line, a forwarder adding an unsubscribe header — invalidates the signature.

If a message passes through a system that modifies it and that system doesn’t re-sign it, DKIM fails at the destination. This is why mailing lists were the biggest casualty when large providers moved to p=reject.

Mailing lists and From rewriting

A mailing list that sends a message “from” the original author, with the list’s own envelope sender, will have SPF fail (wrong connecting server) and DKIM fail (modified body/footer). The list either has to rewrite the From header to the list’s own address, or wrap the message in ARC (Authenticated Received Chain, RFC 8617) so the receiver can verify the original authentication passed at the list’s boundary.

This is a real, ongoing problem. If you run a list, plan for ARC. If you send through a list, test that your messages still pass DMARC after the list has touched them.

pct= is not a rollback

The pct= tag is supposed to apply the policy to only a percentage of failing mail, letting you roll out reject gradually. In practice many major receivers ignore it and apply the full policy. Treat pct= as advisory at best and test with p=none/p=quarantine instead of trusting a percentage.

ruf= forensic reports mostly don’t arrive

Forensic (failure) reports contain full headers and sometimes body fragments, so most large receivers stopped sending them over privacy concerns. You’ll get aggregate (rua=) reports reliably; you will likely get few or no forensic reports. Build your tooling around aggregate data.

Report addresses and external domains

If your rua= points at an address outside the domain the record belongs to — a DMARC analytics service, or just a different domain you own — the receiving domain has to opt in, or reports are silently not sent. This is “external destination verification” (RFC 7489 §7.1), and it is a specific TXT record on the destination side:

example.com._report._dmarc.analytics.example  TXT  "v=DMARC1"

Read it as ”analytics.example agrees to accept DMARC reports about example.com”. Analytics services publish this for you when you add a domain to their dashboard, which is why it usually goes unnoticed until you point rua= at a domain you own yourself and no reports ever arrive.

Reports to an address on the same domain need no such record. They do need a mailbox that actually exists and accepts mail from strangers — reports arrive from the reporting receiver’s own domain (Google, Microsoft, Yahoo), so a restrictive inbound filter can quietly discard them.

Subdomains inherit silently

If you publish DMARC at the organizational domain and don’t set sp=, subdomains inherit the parent’s p= policy. A marketing subdomain you forgot about is now bound by p=reject. Be explicit: either set sp= deliberately or publish a _dmarc record on every subdomain you care about.

Softfail vs hardfail is invisible to DMARC

Whether your SPF record ends in ~all (softfail) or -all (hardfail), DMARC treats both as SPF failure for alignment purposes. Softfail softens the message for non-DMARC receivers; under DMARC, a softfail is still a fail.

DMARC doesn’t look at Reply-To

Attackers set a forged From and a Reply-To pointing at themselves. DMARC only checks From. This is by design — but it’s worth remembering that DMARC stops spoofing your domain in From, not social engineering that abuses other headers.

Best practices

  1. Start at p=none with rua= and read the reports for at least a month before tightening. You cannot see what breaks until real mail flows.

  2. Get DKIM right first. DKIM is the mechanism that survives forwarding and mailing lists, so it’s the more durable half of DMARC. Prioritise it over endless SPF tuning.

  3. Use relaxed alignment unless you have a specific reason for strict. Strict is more correct but breaks subdomain senders you may not have mapped.

  4. Keep SPF flat. Monitor your lookup count; flatten before you hit 10. Tools like dmarcian and mxtoolbox report your lookup depth.

  5. Route third-party senders through their own subdomain (marketing., mail.) rather than piling includes into the parent SPF. It isolates their failures from your main domain.

  6. Publish DMARC on parked/never-sending domains too, at p=reject. A domain that never sends mail is the easiest to spoof and the easiest to protect.

  7. Don’t treat quarantine as final. Some receivers downgrade quarantine to inbox. If you need spoofed mail gone, the end state is reject.

  8. Re-validate after any DNS or sending change. A new provider, a changed From, or a new subdomain can silently shift alignment.

Testing it

  • dig +short TXT _dmarc.example.com to confirm the record is live and correctly formatted.
  • mxtoolbox.com/dmarc.aspx and dmarcian.com/dmarc-inspector parse your record and flag common mistakes (wrong version order, missing p=, tag typos).
  • Send test mail to Gmail and Yahoo from every sender you use, then inspect the Authentication-Results header the receiver adds. It shows the SPF result, DKIM result, and DMARC result for that exact message.
  • Read the aggregate reports. They’re gzip-compressed XML and unusable by hand; point rua= at a tool (dmarcian, Postmark’s free parser, or a self-hosted one) rather than a mailbox nobody reads.

DMARC is a system that rewards patience and punishes shortcuts. The people who get burned are almost always the ones who set p=reject on a Friday afternoon and discovered on Monday that their CRM’s email was being bounced. Set up the reports, watch them, tighten deliberately — and your domain becomes the one spoofers avoid.

  • #dmarc
  • #dns
  • #email
  • #security