How to check DMARC record

Query TXT on the _dmarc label of the domain: nslookup -type=TXT _dmarc.github.com 8.8.8.8. The answer starts with v=DMARC1 and carries the policy in p=. Read sp= as well, because subdomains follow it and it is often stricter than p=.

Why check this

Run this before any release that sends mail from a new subdomain, and again after a DNS cutover. The policy decides what a receiver does with a message that fails authentication, so it is the difference between a broken sending path that degrades quietly and one that loses transactional mail outright. A staging system that starts sending as mail.staging.example.com inherits whatever the parent domain publishes, which can be reject.

Two facts in the record are usually skipped and both change the answer. sp= governs subdomains independently of p=. And a rua= address on another domain needs that domain's permission before any report is sent, so a policy can be live for months while you receive nothing and conclude nobody is spoofing you.

Prerequisites

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

(async () => {
  const d = process.argv[2];
  let at = '_dmarc.' + d;
  let found = (await txt(at)).filter(s => s.startsWith('v=DMARC1'));
  if (!found.length && org(d) !== d) {               // RFC 7489 organizational domain fallback
    at = '_dmarc.' + org(d);
    found = (await txt(at)).filter(s => s.startsWith('v=DMARC1'));
    console.log('nothing at _dmarc.' + d + ', falling back to the organizational domain');
  }
  console.log('answered at      : ' + at);
  console.log('DMARC records    : ' + found.length + (found.length === 1 ? '' : '  <-- must be exactly 1'));
  if (found.length !== 1) return;
  const rec = found[0];
  const t = Object.fromEntries(rec.split(';').map(s => s.trim().split('='))
    .filter(p => p.length === 2).map(([k, v]) => [k.toLowerCase(), v]));
  console.log('p                : ' + (t.p || 'MISSING, the tag is required'));
  console.log('sp (subdomains)  : ' + (t.sp || 'not set, subdomains inherit p=' + t.p));
  console.log('pct              : ' + (t.pct || '100 (default)'));
  console.log('aspf / adkim     : ' + (t.aspf || 'r (default)') + ' / ' + (t.adkim || 'r (default)'));
  console.log('rua              : ' + (t.rua || 'none, no aggregate reports will arrive'));
  console.log('ruf              : ' + (t.ruf || 'none'));
  for (const dest of [t.rua, t.ruf].filter(Boolean).join(',').split(',')) {
    const host = dest.trim().replace(/^mailto:/, '').split('@')[1];
    if (!host || org(host) === org(d)) continue;      // same org domain needs no authorization
    const auth = await txt(d + '._report._dmarc.' + host);
    console.log('external report  : ' + d + '._report._dmarc.' + host +
      (auth.length ? ' = ' + auth.join(' ') : ' = MISSING, reports are not sent'));
  }
  console.log('record           : ' + rec);
})();

Steps

  1. Step 1.

    Read the record straight from a named resolver.

    nslookup -type=TXT _dmarc.github.com 8.8.8.8
    
    Non-authoritative answer:
    Server:  dns.google
    Address:  8.8.8.8
    
    _dmarc.github.com	text =
    
    "v=DMARC1; p=quarantine; sp=reject; pct=100; rua=mailto:dmarc@github.com; ruf=mailto:dmarc@github.com; fo=1"

    The label is _dmarc on the From domain. There is no such thing as a DMARC record on the bare domain.

  2. Step 2.

    Split the record into tags, with the defaults filled in.

    node dmarc-record.js github.com
    
    answered at      : _dmarc.github.com
    DMARC records    : 1
    p                : quarantine
    sp (subdomains)  : reject
    pct              : 100
    aspf / adkim     : r (default) / r (default)
    rua              : mailto:dmarc@github.com
    ruf              : mailto:dmarc@github.com
    record           : v=DMARC1; p=quarantine; sp=reject; pct=100; rua=mailto:dmarc@github.com; ruf=mailto:dmarc@github.com; fo=1

    p=quarantine and sp=reject together mean the organizational domain is at spam-folder enforcement while every subdomain is at full rejection. Reading p alone gets a subdomain test wrong.

  3. Step 3.

    Check a subdomain, which usually has no record of its own.

    node dmarc-record.js blog.cloudflare.com
    
    nothing at _dmarc.blog.cloudflare.com, falling back to the organizational domain
    answered at      : _dmarc.cloudflare.com
    DMARC records    : 1
    p                : reject
    sp (subdomains)  : reject
    pct              : 100
    aspf / adkim     : r / r
    rua              : mailto:a1c47f179bc04efd8ee4dcd4d85dfc65@dmarc-reports.cloudflare.net,mailto:rua@cloudflare.com
    ruf              : none
    external report  : blog.cloudflare.com._report._dmarc.dmarc-reports.cloudflare.net = v=DMARC1;
    record           : v=DMARC1; p=reject; sp=reject; adkim=r; aspf=r; pct=100; rua=…,mailto:rua@cloudflare.com

    An empty answer at the subdomain is not "no DMARC". The receiver walks up to the organizational domain and applies sp= from there.

  4. Step 4.

    Verify that every external report address has authorised this domain.

    node dmarc-record.js mozilla.org
    
    answered at      : _dmarc.mozilla.org
    DMARC records    : 1
    p                : reject
    sp (subdomains)  : not set, subdomains inherit p=reject
    pct              : 100
    aspf / adkim     : r / r
    rua              : mailto:dmarc_agg@vali.email,mailto:dmarc@mozilla.com
    ruf              : none
    external report  : mozilla.org._report._dmarc.vali.email = v=DMARC1;
    external report  : mozilla.org._report._dmarc.mozilla.com = v=DMARC1;
    record           : v=DMARC1; p=reject; pct=100; adkim=r; aspf=r; rua=mailto:dmarc_agg@vali.email,mailto:dmarc@mozilla.com

    Both destinations sit outside mozilla.org, including mozilla.com, so both need an authorisation record and both have one.

  5. Step 5.

    Find out whether that authorisation is specific to you or a wildcard.

    node -e "const{Resolver}=require('dns');const r=new Resolver();r.setServers(['8.8.8.8']);r.resolveTxt('zzz-no-such-domain-42.example._report._dmarc.vali.email',(e,x)=>console.log(e?e.code:x.flat().join(' ')))"
    
    v=DMARC1;

    A name that cannot exist gets the same answer. The report host publishes a wildcard, so a pass on step 4 proves the host accepts reports for anyone, not that somebody set your domain up correctly.

How to read the result

| What you see | What it means | What to do | | --- | --- | --- | | p=none | Reporting only, nothing is enforced | Fine while you gather data. It stops no spoofing. | | p=quarantine | Failing mail goes to the spam folder | Check sp= before assuming subdomains behave the same way. | | p=reject | Failing mail is refused at the gateway | Every sending system needs an aligned pass first. See How to check DMARC alignment. | | p : MISSING | The record has no valid policy | With a valid rua, RFC 7489 section 6.6.3 has receivers act as if it said p=none and keep reporting. Without one, they skip DMARC for the message. Neither enforces anything. | | pct=20 | One message in five gets the policy | Under reject, the other four are quarantined. Under quarantine, they get the receiver's normal filtering. Test results will look random. | | rua : none | No aggregate reports are produced | Add one before moving off p=none, or the move is blind. | | DMARC records : 2 | The policy is ignored | Two v=DMARC1 records are a discovery failure. Remove one. |

Common mistakes

Sign: The record says p=quarantine and mail from a subdomain is rejected outright.Cause: sp= sets a separate policy for subdomains and overrides p= for them. On 2026-09-11, github.com published p=quarantine with sp=reject, so the parent was at quarantine and every subdomain at reject. Reading p alone gives the wrong answer for anything that sends from a subdomain.
Sign: A subdomain returns no DMARC record, so the conclusion is that DMARC does not apply to it.Cause: Discovery falls back to the organizational domain, so a missing record at the subdomain means the parent's policy applies. The resolver makes this worse: three consecutive queries for _dmarc.support.github.com here returned ENOTFOUND, ENODATA and ENOTFOUND. Both codes mean the same thing for DMARC, so treat them alike and always run the fallback query.
Sign: A policy has been live for months and no aggregate report has ever arrived.Cause: When the rua address is on a different organizational domain, RFC 7489 requires that domain to publish yourdomain._report._dmarc.theirdomain. Without it a receiver is not allowed to send the report, and nothing warns either side. Step 4 is the query that finds this.
Sign: The external authorisation record is present, so the reporting setup is assumed correct.Cause: Report hosts commonly publish a wildcard under _report._dmarc. Step 5 asked for a domain that does not exist and got v=DMARC1; back. The check can prove reporting is broken, but a pass tells you nothing about your own domain.

What to check next

FAQ

How to check a DMARC record using nslookup?

nslookup -type=TXT _dmarc.example.com 8.8.8.8. Keep the _dmarc label and the trailing resolver argument. Without the label the query returns the domain's ordinary TXT set and no policy.

How to check DMARC policy for a subdomain?

Query the subdomain's own _dmarc label first. If nothing answers, query the organizational domain and read sp= there, because that is the tag a receiver applies to the subdomain.

How to check if DMARC is enabled for a domain?

A record alone is not enforcement. p=none publishes a policy that changes no delivery decision. DMARC is enforced when p or the relevant sp is quarantine or reject and pct is 100.

How to check a BIMI record?

Query TXT on default._bimi.example.com. Receivers only look at it when the domain is already at p=quarantine or p=reject, so check the DMARC policy first or the BIMI record cannot take effect.

What does the fo tag do?

It selects when failure reports are generated. fo=1 asks for a report whenever any underlying check fails, rather than only when both do. It has no effect on delivery.

Verified

Verified by Maks Vernynslookup Windows 10.0.22631node 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.

basic5 minpublished updated Maks Verny