How to check DMARC alignment

Compare three domains from one message: the From: header, the Return-Path that SPF authenticated, and the d= tag of the DKIM signature. DMARC passes only when one of the latter two matches the From: domain under the mode in aspf= or adkim=. Neither has to equal the other.

Why check this

Run this the first time a service sends production mail through a new provider, and again whenever the provider changes the bounce domain. The failure it catches is the one that reads as impossible in a ticket: SPF passes, DKIM passes, DMARC fails, and the receipt never arrives. Every individual check is green because each authenticates the provider, and DMARC is the only one that asks whether either of them authenticated the domain in the From: header.

Alignment is a comparison, not a lookup, so a checker that reports "SPF: pass, DKIM: pass" without printing the three domains has not answered the question. The procedure below prints all three next to the record that judges them.

Prerequisites

Return-Path: <bounces+4821-9f2a@sendgrid.net>
Received: from o1.sendgrid.net (o1.sendgrid.net [167.89.12.34])
	by mx.local (Postfix) with ESMTPS id 4C2F1A9
	for <qa@h2check.test>; Fri, 11 Sep 2026 09:14:02 +0000
DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=sendgrid.net;
	s=smtpapi; h=from:subject:to; bh=47DEQpj8HBSa+/TImW+5JCeuQeR=;
	b=Zk9m2Qw1Jt7pR0sXbL4hGf8nCvY6aUeD3iK5oM1rT2wH
From: Example Billing <billing@example.com>
To: qa@h2check.test
Subject: Invoice 2026-0914
Message-ID: <20260911091402.4C2F1A9@sendgrid.net>

Your invoice is attached.
Return-Path: <bounce-77@mail.example.com>
Received: from mail.example.com (mail.example.com [93.184.216.34])
	by mx.local (Postfix) with ESMTPS id 71B03C4
	for <qa@h2check.test>; Fri, 11 Sep 2026 09:16:40 +0000
DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=mail.example.com;
	s=s1; h=from:subject:to; bh=47DEQpj8HBSa+/TImW+5JCeuQeR=;
	b=Qb3nW8vL1yE6tA0dKpXcR5fJ2mHs9uZ4gN7iO1rM3wT
From: Example Billing <billing@example.com>
To: qa@h2check.test
Subject: Invoice 2026-0915
Message-ID: <20260911091640.71B03C4@mail.example.com>

Your invoice is attached.
const fs = require('fs');
const { Resolver } = require('dns');
const r = new Resolver();
r.setServers(['8.8.8.8']);          // name the resolver: Node's default is not always usable
const txt = n => new Promise(ok => r.resolveTxt(n, (e, recs) => ok(e ? [] : recs.map(c => c.join('')))));
const org = d => d.split('.').slice(-2).join('.');   // two labels; the real rule is the Public Suffix List
const unfold = raw => raw.replace(/\r?\n[ \t]+/g, ' ').split(/\r?\n/);
const head = (lines, name) =>
  (lines.find(l => l.toLowerCase().startsWith(name + ':')) || '').slice(name.length + 1).trim();

(async () => {
  const lines = unfold(fs.readFileSync(process.argv[2], 'utf8').split(/\r?\n\r?\n/)[0]);
  const from = head(lines, 'from').match(/@([^>\s]+)/)[1].toLowerCase();        // the identity DMARC protects
  const env = head(lines, 'return-path').match(/@([^>\s]+)/)[1].toLowerCase();  // the identity SPF authenticates
  const dkim = head(lines, 'dkim-signature').match(/[;\s]d=([^;\s]+)/)[1].toLowerCase();

  const rec = (await txt('_dmarc.' + from)).find(s => s.startsWith('v=DMARC1')) ||
              (await txt('_dmarc.' + org(from))).find(s => s.startsWith('v=DMARC1'));
  const t = Object.fromEntries(rec.split(';').map(s => s.trim().split('='))
    .filter(p => p.length === 2).map(([k, v]) => [k.toLowerCase(), v]));

  const row = (label, id, mode) => {
    const strict = id === from;                 // adkim=s / aspf=s: the domains must be identical
    const relaxed = org(id) === org(from);      // adkim=r / aspf=r: same organizational domain is enough
    const ok = mode === 's' ? strict : relaxed;
    console.log(label.padEnd(5) + id.padEnd(21) + 'org=' + org(id).padEnd(15) +
      'relaxed=' + String(relaxed).padEnd(6) + 'strict=' + String(strict).padEnd(6) +
      'mode=' + mode + ' -> ' + (ok ? 'ALIGNED' : 'NOT ALIGNED'));
    return ok;
  };

  console.log('From header domain : ' + from + '   org=' + org(from));
  console.log('DMARC record       : ' + rec);
  console.log('');
  const spfOk = row('SPF', env, t.aspf || 'r');
  const dkimOk = row('DKIM', dkim, t.adkim || 'r');
  console.log('');
  console.log('DMARC = ' + (spfOk || dkimOk ? 'pass' : 'fail') + '; policy in force: p=' + (t.p || 'none'));
})();

Steps

  1. Step 1.

    Confirm that SPF really does pass for the envelope domain in esp.eml.

    node -e "const{Resolver}=require('dns');const r=new Resolver();r.setServers(['8.8.8.8']);r.resolveTxt('sendgrid.net',(e,x)=>{const s=x.map(c=>c.join('')).find(t=>t.startsWith('v=spf1'));console.log(s.split(' ').filter(t=>t.startsWith('ip4:167')).join(' '))})"
    
    ip4:167.89.0.0/17

    The Received header names 167.89.12.34 as the connecting address, and that address is inside 167.89.0.0/17. SPF passes, for sendgrid.net.

  2. Step 2.

    Read the alignment modes the From: domain publishes.

    node dmarc-record.js example.com
    
    answered at      : _dmarc.example.com
    DMARC records    : 1
    p                : reject
    sp (subdomains)  : reject
    pct              : 100 (default)
    aspf / adkim     : s / s
    rua              : none, no aggregate reports will arrive
    ruf              : none
    record           : v=DMARC1;p=reject;sp=reject;adkim=s;aspf=s

    aspf=s and adkim=s are strict. The reader for this step is in How to check DMARC record.

  3. Step 3.

    Evaluate the provider message.

    node dmarc-align.js esp.eml
    
    From header domain : example.com   org=example.com
    DMARC record       : v=DMARC1;p=reject;sp=reject;adkim=s;aspf=s
    
    SPF  sendgrid.net         org=sendgrid.net   relaxed=false strict=false mode=s -> NOT ALIGNED
    DKIM sendgrid.net         org=sendgrid.net   relaxed=false strict=false mode=s -> NOT ALIGNED
    
    DMARC = fail; policy in force: p=reject

    This is the result the ticket calls impossible. SPF passed in step 1 and the signature is valid, yet both identifiers say sendgrid.net while the reader sees example.com. Under p=reject the message is refused.

  4. Step 4.

    Evaluate the subdomain message, where relaxed and strict disagree.

    node dmarc-align.js subdomain.eml
    
    From header domain : example.com   org=example.com
    DMARC record       : v=DMARC1;p=reject;sp=reject;adkim=s;aspf=s
    
    SPF  mail.example.com     org=example.com    relaxed=true  strict=false mode=s -> NOT ALIGNED
    DKIM mail.example.com     org=example.com    relaxed=true  strict=false mode=s -> NOT ALIGNED
    
    DMARC = fail; policy in force: p=reject

    relaxed=true strict=false on both rows. The same message would pass under a record that sets aspf=r, and fails here only because this domain chose s.

  5. Step 5.

    Confirm that the mode is a per-domain choice, not a constant.

    for d in example.com cloudflare.com github.com; do echo -n "$d  "; node dmarc-record.js $d | grep 'aspf /'; done
    
    example.com  aspf / adkim     : s / s
    cloudflare.com  aspf / adkim     : r / r
    github.com  aspf / adkim     : r (default) / r (default)

    Two of the three are relaxed, one by default. A verdict copied from another domain's record is worth nothing.

How to read the result

| What you see | What it means | What to do | | --- | --- | --- | | Both rows ALIGNED | DMARC passes | Nothing. One aligned pass is enough. | | SPF not aligned, DKIM aligned | DMARC passes | Normal for provider mail with a default bounce domain. Leave it. | | Both not aligned, different org domains | The provider was authenticated, your domain was not | Set a custom return path and a DKIM d= on your own domain at the provider. | | relaxed=true strict=false with mode=s | A subdomain is failing a strict record | Sign as the exact From: domain, or move the record to aspf=r. | | relaxed=false for DKIM only | The signature is the provider's, not yours | Ask the provider for a domain-signing key and publish its selector. | | p=none under a failing verdict | Nothing is blocked yet | Fix alignment before moving to quarantine, not after. |

Common mistakes

Sign: SPF passes, the DKIM signature verifies, and DMARC still fails.Cause: SPF authenticates the Return-Path and DKIM authenticates its own d= domain. Neither of them reads the From: header, which is the only address a person sees. DMARC is the check that compares them, so a pass on both inputs carries no information about the result until the three domains are printed side by side.
Sign: Mail from a subdomain fails while mail from the parent domain passes.Cause: Relaxed alignment accepts any subdomain of the organizational domain and strict accepts nothing but an exact match. Step 4 shows the same identifiers scoring relaxed=true and strict=false. The record decides which column counts, and aspf can differ from adkim in the same record.
Sign: An alignment script agrees with the online checkers on .com domains and disagrees on .co.uk.Cause: The organizational domain comes from the Public Suffix List, not from counting labels. The script above takes the last two labels, which is correct for example.com and wrong for example.co.uk, where it would compare co.uk. Load the list before running this against anything under a multi-label suffix.
Sign: The exported message has no Return-Path header.Cause: Return-Path is added by the delivering server, so a message saved from the Sent folder or from a forward has never had one. Export the copy that arrived in the destination mailbox, with full headers, or the SPF row cannot be computed at all.

What to check next

FAQ

Why does DMARC fail when SPF and DKIM pass?

Because both passed for a domain that is not the one in the From: header. A provider's bounce domain and a provider's signing domain each authenticate the provider. DMARC asks a different question, and step 3 shows it answering no.

What is the difference between relaxed and strict alignment?

Relaxed accepts any subdomain of the same organizational domain. Strict requires the domains to be identical. The aspf and adkim tags set them separately, and both default to relaxed when the record omits them.

Does DMARC need both SPF and DKIM to align?

No. One aligned pass is enough. DKIM is the more useful of the two, because it survives forwarding while SPF does not.

Can I check alignment without sending a message?

Only partly. The DMARC record and its modes are a DNS read, but the three identifiers come from a delivered message. Keep one known-good .eml per sending system as a fixture and re-run this after every provider change.

Verified

Verified by Maks Vernynode 22.23.2

Each output block is what the command above it printed on that date, on the host named in the step. Figures read from a live site move between runs. Compare the shape of the answer rather than the digits, and see the methodology for how a page is re-verified.

intermediate8 minpublished updated Maks Verny