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
- The message as bytes. Gmail: Show original, Download Original. Outlook on the web: More actions, View, View message source. Save it as
message.eml. - RFC 8601 for the header grammar and the result values.
- Node 22 and
npm install mailauth. The message used below is the one signed in How to verify DKIM signature, so no third party's mail was read. zone.js, a stand-in for the DNS of a domain nobody owns:
// 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, which runs the checks a receiving server runs, anddeliver.jsfor step 4:
// 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
- Step 1.
Run the receiving side on the message as the authorised sender delivered it.
node authcheck.js message.eml 203.0.113.10Received-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=nonemx.h2check.testbefore the first semicolon is the authserv-id, the name of the server that ran the checks. Everything after it is that server's verdict. - Step 2.
Deliver the same bytes from an address the SPF record does not list.
node authcheck.js message.eml 198.51.100.7Received-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=nonespf=failanddmarc=passin one header. DMARC needs one aligned pass, and the DKIM signature supplied it. Anspf=failon its own is not a reason to change the SPF record. - Step 3.
Let a relay append a footer, then run the checks again.
node authcheck.js message.eml 203.0.113.10 --footerReceived-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=noneThe broken signature is reported as
dkim=neutral, notdkim=fail. RFC 8601 section 2.7.1 defines both, and a verifier chooses. - Step 4.
Put an
Authentication-Resultsheader on the message before sending it, and read what arrives.node deliver.jsfield #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=passTwo 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.
- 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 -c1 dkim=neutral 1 dkim=pass 2 dmarc=pass 2 spf=passA search for
dkim=failon this message returns nothing, and the message failed DKIM. Search fordkim=passand 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
What to check next
- How to verify DKIM signature: what
dkim=neutralin step 3 means at the byte level. - How to check SPF record: the record the
spf=result was evaluated against. - How to check DMARC record: the policy named in the
dmarc=comment asp=REJECT. - How to check DMARC alignment: why
header.d=andheader.from=are printed next to each other. - Email testing checklist: where header reading sits in a mail release.
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.
Related on this site
intermediate10 minpublished updated Maks Verny