How to check if an email has passed SPF DKIM and DMARC

Read the Authentication-Results header of the delivered message and look for spf=, dkim= and dmarc=. The header is written by the receiving server after it ran the checks, so it is a record of one server's verdict, not a property of the message.

Why check this

Run this whenever a tester reports that mail from the application reached spam, or after any change to the sending path. It is the only place a receiver writes down what it concluded, and it is the fastest way to tell a DNS problem from a message problem. The failure it prevents: a team spends a day rewriting SPF because spf=fail is in the header, while the message was delivered on a DKIM pass and the real problem is elsewhere.

The header is a claim, not evidence. Anything before your own boundary MTA was written by somebody else, and a sender can put whatever it likes at the top of a message it composes. The steps below run the receiving side on this machine so that the verdicts can be compared with the header the message already carries.

Prerequisites

// A stand-in zone for a domain nobody owns, so the whole run is reproducible.
const fs = require('fs');
const records = {
  'ci2026._domainkey.build.example': fs.readFileSync('dkim.record.txt', 'utf8').trim(),
  'build.example': 'v=spf1 ip4:203.0.113.10 -all',
  '_dmarc.build.example': 'v=DMARC1; p=reject; adkim=s; aspf=s; rua=mailto:dmarc@build.example',
};
module.exports = async function resolver(name, type) {
  if (type === 'TXT' && records[name]) return [[records[name]]];
  throw Object.assign(new Error('ENOTFOUND'), { code: 'ENOTFOUND' });
};
// authcheck.js
const fs = require('fs');
const { authenticate } = require('mailauth');
const resolver = require('./zone.js');

const [file, ip, footer] = process.argv.slice(2);
let msg = fs.readFileSync(file, 'binary');
if (footer === '--footer') msg += 'Sent by ci-relay.\r\n';   // what a relay adds on the way out

(async () => {
  const res = await authenticate(Buffer.from(msg, 'binary'), {
    ip, helo: 'ci.build.example', sender: 'ci@build.example',
    mta: 'mx.h2check.test', resolver,
  });
  console.log(res.headers.trim());
})();

// deliver.js
const fs = require('fs');
const { authenticate } = require('mailauth');
const resolver = require('./zone.js');

// What the sender puts at the top of the message before it leaves its own network.
const forged = 'Authentication-Results: mx.h2check.test; spf=pass; dkim=pass; dmarc=pass\r\n';
const sent = forged + fs.readFileSync('message.eml', 'binary') + 'Sent by ci-relay.\r\n';

(async () => {
  const res = await authenticate(Buffer.from(sent, 'binary'), {
    ip: '203.0.113.10', helo: 'ci.build.example', sender: 'ci@build.example',
    mta: 'mx.h2check.test', resolver,
  });
  fs.writeFileSync('delivered.eml', res.headers + sent, 'binary');
  const head = fs.readFileSync('delivered.eml', 'utf8').split('\r\n\r\n')[0];
  head.split(/\r\n(?![ \t])/).forEach((field, i) => {
    if (!/^Authentication-Results:/i.test(field)) return;
    console.log('field #' + (i + 1) + '  ' + field.replace(/\r\n\s+/g, ' '));
  });
})();

Steps

  1. Step 1.

    Run the receiving side on the message as the authorised sender delivered it.

    node authcheck.js message.eml 203.0.113.10
    
    Received-SPF: pass (mx.h2check.test: domain of ci@build.example designates 203.0.113.10 as permitted sender) client-ip=203.0.113.10;
    Authentication-Results: mx.h2check.test;
    dkim=pass header.i=@build.example header.s=ci2026 header.a=rsa-sha256 header.b=fjhLXXHe;
    spf=pass (mx.h2check.test: domain of ci@build.example designates 203.0.113.10 as permitted sender) smtp.mailfrom=ci@build.example smtp.helo=ci.build.example;
    dmarc=pass (p=REJECT arc=none) header.from=build.example header.d=build.example;
    bimi=none

    mx.h2check.test before the first semicolon is the authserv-id, the name of the server that ran the checks. Everything after it is that server's verdict.

  2. Step 2.

    Deliver the same bytes from an address the SPF record does not list.

    node authcheck.js message.eml 198.51.100.7
    
    Received-SPF: fail (mx.h2check.test: domain of ci@build.example does not designate 198.51.100.7 as permitted sender) client-ip=198.51.100.7;
    Authentication-Results: mx.h2check.test;
    dkim=pass header.i=@build.example header.s=ci2026 header.a=rsa-sha256 header.b=fjhLXXHe;
    spf=fail (mx.h2check.test: domain of ci@build.example does not designate 198.51.100.7 as permitted sender) smtp.mailfrom=ci@build.example
    smtp.helo=ci.build.example;
    dmarc=pass (p=REJECT arc=none) header.from=build.example header.d=build.example;
    bimi=none

    spf=fail and dmarc=pass in one header. DMARC needs one aligned pass, and the DKIM signature supplied it. An spf=fail on its own is not a reason to change the SPF record.

  3. Step 3.

    Let a relay append a footer, then run the checks again.

    node authcheck.js message.eml 203.0.113.10 --footer
    
    Received-SPF: pass (mx.h2check.test: domain of ci@build.example designates 203.0.113.10 as permitted sender) client-ip=203.0.113.10;
    Authentication-Results: mx.h2check.test;
    dkim=neutral (body hash did not verify) header.i=@build.example header.s=ci2026 header.a=rsa-sha256 header.b=fjhLXXHe;
    spf=pass (mx.h2check.test: domain of ci@build.example designates 203.0.113.10 as permitted sender) smtp.mailfrom=ci@build.example smtp.helo=ci.build.example;
    dmarc=pass (p=REJECT arc=none) header.from=build.example header.d=build.example;
    bimi=none

    The broken signature is reported as dkim=neutral, not dkim=fail. RFC 8601 section 2.7.1 defines both, and a verifier chooses.

  4. Step 4.

    Put an Authentication-Results header on the message before sending it, and read what arrives.

    node deliver.js
    
    field #2  Authentication-Results: mx.h2check.test; dkim=neutral (body hash did not verify) header.i=@build.example header.s=ci2026 header.a=rsa-sha256 header.b=fjhLXXHe; spf=pass (mx.h2check.test: domain of ci@build.example designates 203.0.113.10 as permitted sender) smtp.mailfrom=ci@build.example smtp.helo=ci.build.example; dmarc=pass (p=REJECT arc=none) header.from=build.example header.d=build.example; bimi=none
    field #3  Authentication-Results: mx.h2check.test; spf=pass; dkim=pass; dmarc=pass

    Two headers, one authserv-id, opposite verdicts on DKIM. Field #3 was written by the sender. RFC 8601 section 5 tells the receiving MTA to delete headers carrying its own authserv-id before adding its own, and this one did not.

  5. Step 5.

    Count every verdict token in the delivered message rather than reading the first one.

    grep -o -E '(dkim|spf|dmarc)=[a-z]+' delivered.eml | sort | uniq -c
    
          1 dkim=neutral
        1 dkim=pass
        2 dmarc=pass
        2 spf=pass

    A search for dkim=fail on this message returns nothing, and the message failed DKIM. Search for dkim=pass and count how many headers claim it.

How to read the result

| What you see | What it means | What to do | | --- | --- | --- | | One Authentication-Results from your own MTA | The normal case | Read dmarc= first. It is the verdict that decides handling. | | Two or more with the same authserv-id | One of them was not written by your MTA | Trust the topmost one below your own Received headers. Treat the rest as message content. | | dkim=neutral (body hash did not verify) | The body changed after signing | Look for a relay or a footer injector, not for a DNS problem. | | spf=fail with dmarc=pass | DKIM carried the message | No SPF change is needed. Check the forwarding path instead. | | dkim=temperror or spf=temperror | A DNS lookup failed at check time | Re-send. The result says nothing about the record. | | header.d= differs from header.from= | A third party signed | Alignment decides whether that pass counts for DMARC. | | No Authentication-Results at all | The receiver ran no checks, or you are reading the sent copy | Read a message from the recipient's mailbox, not from Sent. |

Common mistakes

Sign: A grep for dkim=fail comes back empty and the message still failed DKIM.Cause: RFC 8601 section 2.7.1 defines fail, neutral, policy, temperror and permerror as separate results. A broken body hash is reported here as dkim=neutral (body hash did not verify). Match on dkim=pass and treat everything else as not passing.
Sign: The header says every check passed, and the receiving domain still rejected the message.Cause: The header was written before the message left the sending network. Step 4 shows two Authentication-Results fields with the same authserv-id and opposite DKIM verdicts in one delivered message. Only the one added by your own boundary MTA, above the Received header it wrote, is a verdict.
Sign: A forwarded copy of the message shows different results from the original.Cause: The forwarder ran the checks again on its own connection, and it is the forwarder's IP that SPF was evaluated against. Ask for the original as an attachment, or for the raw source, never for a forward.
Sign: spf=fail sends the team to rewrite the SPF record and nothing improves.Cause: DMARC passes on one aligned pass, from SPF or from DKIM. Step 2 shows spf=fail next to dmarc=pass on the same message. Read dmarc= before deciding which record is at fault.

What to check next

FAQ

How to check email headers?

Open the raw source, not the reading pane. Gmail: Show original. Outlook on the web: View message source. Thunderbird: Ctrl+U. Read from the bottom up, because each hop prepends its own Received and Authentication-Results.

How to check email headers for spoofing?

Compare the From: domain with header.d= and smtp.mailfrom= in the Authentication-Results your own server wrote. A forged From: shows up as dmarc=fail, and only the header your own server added is worth reading.

Which header should I trust?

The topmost Authentication-Results carrying your own server's authserv-id, and only if it sits above the Received header that server wrote. Everything below that came in with the message.

Does spf=none mean the message is a forgery?

No. It means the sending domain publishes no SPF record. A domain can pass DMARC on DKIM alone, which is what dmarc= is there to tell you.

Can the sender see the result the receiver recorded?

Only through DMARC aggregate reports, which go to the address in the rua= tag of the DMARC record. The header itself stays in the recipient's copy.

Verified

Verified by Maks Vernynode 22.23.2mailauth 5.0.3grep GNU grep 3.0

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.

intermediate10 minpublished updated Maks Verny