How to verify DKIM signature
Hash the canonicalised body and compare it with bh=, then verify the RSA signature in b= over the headers named in h=, using the key at <s>._domainkey.<d>. Run node v.js message.eml. A pass needs both halves to match, and the two are reported separately.
Why check this
Run this when DKIM fails at one receiver and passes at another, or when mail starts failing after a relay, a mailing list, or an archiving appliance was put in the path. The failure it prevents: a footer injector added one line to every outgoing message, every signature broke, and the only symptom was a slow drift into spam folders.
The check has two halves and they fail for different reasons. A body hash mismatch means the bytes of the body changed after signing. A header signature mismatch means a signed header changed, or the key is wrong. Reading which half failed tells you where to look, and most tools collapse both into one word.
Prerequisites
- RFC 6376 section 3.4 for the two canonicalisation algorithms, and section 3.7 for the hash inputs.
- openssl 3 and Node 22. Generate a key pair and the record a verifier would read:
openssl genrsa -out dkim.private.pem 2048 && printf 'v=DKIM1; k=rsa; p=%s\n' "$(openssl rsa -in dkim.private.pem -pubout -outform DER 2>/dev/null | openssl base64 -A)" > dkim.record.txt
- An unsigned message to work on, saved as
base.emlwith CRLF line endings:
From: CI <ci@build.example>
To: qa@build.example
Subject: Build 4821 failed on main
Date: Fri, 11 Sep 2026 20:00:00 +0000
Message-ID: <4821.build@build.example>
MIME-Version: 1.0
Content-Type: text/plain; charset=utf-8
X-Build-Number: 4821
Job: integration-suite
Status: failed
Failing test: checkout_applies_discount
Logs: https://ci.build.example/4821
dkim.js, the signer and verifier used by every step. rsa-sha256 only:
const crypto = require('crypto');
function splitMessage(raw) {
const s = raw.replace(/\r?\n/g, '\r\n');
const i = s.indexOf('\r\n\r\n');
const headerText = s.slice(0, i + 2); // keep the CRLF that ends the last header
const body = s.slice(i + 4);
const headers = [];
for (const chunk of headerText.split(/\r\n(?![ \t])/)) {
if (!chunk) continue;
headers.push({ name: chunk.slice(0, chunk.indexOf(':')), raw: chunk + '\r\n' });
}
return { headers, body };
}
function canonHeader(h, alg) { // RFC 6376 sections 3.4.1 and 3.4.2
if (alg === 'simple') return h.raw;
const c = h.raw.indexOf(':');
const name = h.raw.slice(0, c).toLowerCase().trim();
const value = h.raw.slice(c + 1).replace(/\r\n/g, '').replace(/[ \t]+/g, ' ').trim();
return name + ':' + value + '\r\n';
}
function canonBody(body, alg) { // RFC 6376 sections 3.4.3 and 3.4.4
if (alg === 'simple') {
const b = body.replace(/(\r\n)*$/, '');
return b === '' ? '\r\n' : b + '\r\n';
}
const lines = body.split('\r\n').map((l) => l.replace(/[ \t]+/g, ' ').replace(/[ \t]+$/, ''));
while (lines.length && lines[lines.length - 1] === '') lines.pop();
return lines.length ? lines.join('\r\n') + '\r\n' : '';
}
function pickSigned(headers, hList) { // RFC 6376 section 5.4.2: bottom-most instance first
const pool = headers.map((h) => ({ ...h, used: false }));
const out = [];
for (const want of hList) {
for (let i = pool.length - 1; i >= 0; i -= 1) {
if (!pool[i].used && pool[i].name.toLowerCase() === want.toLowerCase()) {
pool[i].used = true; out.push(pool[i]); break;
}
}
}
return out;
}
function sign(raw, opts) {
const { headers, body } = splitMessage(raw);
const [hc, bc] = opts.c.split('/');
const bh = crypto.createHash('sha256').update(canonBody(body, bc), 'binary').digest('base64');
const sigHeader = 'DKIM-Signature: v=1; a=rsa-sha256; c=' + opts.c + '; d=' + opts.d + '; s=' + opts.s + ';\r\n'
+ ' t=' + opts.t + '; h=' + opts.h.join(':') + ';\r\n bh=' + bh + ';\r\n b=';
let input = pickSigned(headers, opts.h).map((h) => canonHeader(h, hc)).join('');
input += canonHeader({ name: 'DKIM-Signature', raw: sigHeader + '\r\n' }, hc).replace(/\r\n$/, '');
const b = crypto.sign('sha256', Buffer.from(input, 'binary'), opts.key).toString('base64');
return sigHeader + b.match(/.{1,68}/g).join('\r\n ') + '\r\n'
+ headers.map((h) => h.raw).join('') + '\r\n' + body;
}
function parseTags(s) {
const t = {};
for (const p of s.split(';')) { const i = p.indexOf('='); if (i > 0) t[p.slice(0, i).trim()] = p.slice(i + 1).replace(/\s/g, ''); }
return t;
}
function verify(raw, recordText) {
const { headers, body } = splitMessage(raw);
const sigIdx = headers.findIndex((h) => h.name.toLowerCase() === 'dkim-signature');
if (sigIdx === -1) return { result: 'no DKIM-Signature header' };
const sigRaw = headers[sigIdx].raw;
const tags = parseTags(sigRaw.slice(sigRaw.indexOf(':') + 1));
const [hc, bc = 'simple'] = (tags.c || 'simple/simple').split('/');
const report = { d: tags.d, s: tags.s, a: tags.a, c: hc + '/' + bc, h: tags.h };
const bh = crypto.createHash('sha256').update(canonBody(body, bc), 'binary').digest('base64');
report.bodyHash = bh === tags.bh ? 'match'
: 'MISMATCH (signed ' + tags.bh.slice(0, 12) + '…, computed ' + bh.slice(0, 12) + '…)';
const stripped = sigRaw.replace(/([;\s]b=)[^;]*/, '$1'); // b= is emptied, not removed
let input = pickSigned(headers.filter((_, i) => i !== sigIdx), tags.h.split(':'))
.map((h) => canonHeader(h, hc)).join('');
input += canonHeader({ name: 'DKIM-Signature', raw: stripped }, hc).replace(/\r\n$/, '');
const key = crypto.createPublicKey({ key: Buffer.from(parseTags(recordText).p, 'base64'), format: 'der', type: 'spki' });
const sigOk = crypto.verify('sha256', Buffer.from(input, 'binary'), key, Buffer.from(tags.b, 'base64'));
report.signature = sigOk ? 'match' : 'MISMATCH';
report.result = (bh === tags.bh && sigOk) ? 'pass' : 'fail';
return report;
}
module.exports = { sign, verify };
- The five short drivers the steps call.
npm install mailauthsupplies the second opinion thatxcheck.jsasks for:
// mk.js
const fs = require('fs');
const { sign } = require('./dkim.js');
const key = fs.readFileSync('dkim.private.pem', 'utf8');
const raw = fs.readFileSync('base.eml', 'utf8');
const h = ['from', 'to', 'subject', 'date', 'message-id', 'mime-version', 'content-type'];
for (const c of ['relaxed/relaxed', 'simple/simple']) {
const out = c.split('/')[0] + '.eml';
fs.writeFileSync(out, sign(raw, { c, d: 'build.example', s: 'ci2026', t: 1789502400, h, key }), 'binary');
console.log(out.padEnd(12) + 'c=' + c + ' h=' + h.join(':'));
}
// v.js
const fs = require('fs');
const { verify } = require('./dkim.js');
const rec = fs.readFileSync('dkim.record.txt', 'utf8').trim();
const r = verify(fs.readFileSync(process.argv[2], 'binary'), rec);
for (const [k, v] of Object.entries(r)) console.log(k.padEnd(11) + ': ' + v);
// touch1.js
const fs = require('fs');
const { verify } = require('./dkim.js');
const rec = fs.readFileSync('dkim.record.txt', 'utf8').trim();
for (const f of ['relaxed.eml', 'simple.eml']) {
const m = fs.readFileSync(f, 'binary').replace('Status: failed\r\n', 'Status: failed \r\n');
const r = verify(m, rec);
console.log(f.padEnd(12) + 'c=' + r.c.padEnd(17) + r.result + ' body hash ' + (r.bodyHash === 'match' ? 'match' : 'mismatch'));
}
// mutate.js
const fs = require('fs');
const { verify } = require('./dkim.js');
const rec = fs.readFileSync('dkim.record.txt', 'utf8').trim();
const mutations = {
'trailing space on a body line': (m) => m.replace('Status: failed', 'Status: failed '),
'two spaces inside Subject': (m) => m.replace('Build 4821 failed', 'Build 4821 failed'),
'signed header re-folded': (m) => m.replace('Content-Type: text/plain; charset=utf-8', 'Content-Type: text/plain;\r\n charset=utf-8'),
'unsigned header changed': (m) => m.replace('X-Build-Number: 4821', 'X-Build-Number: 4821-retry'),
'footer line added to body': (m) => m + 'Sent by ci-relay.\r\n',
};
for (const file of ['relaxed.eml', 'simple.eml']) {
const orig = fs.readFileSync(file, 'binary');
console.log('== ' + file + ' c=' + verify(orig, rec).c);
console.log(' unmodified : ' + verify(orig, rec).result);
for (const [label, fn] of Object.entries(mutations)) {
const r = verify(fn(orig), rec);
const why = r.result === 'pass' ? '' : ' [' + (r.bodyHash !== 'match' ? 'body hash' : 'header signature') + ']';
console.log(' ' + label.padEnd(29) + ': ' + r.result + why);
}
console.log();
}
// xcheck.js
const fs = require('fs');
const { authenticate } = require('mailauth');
const record = fs.readFileSync('dkim.record.txt', 'utf8').trim();
// The generated key has no DNS record, so this one name is answered from the local file.
async function resolver(name, type) {
if (type === 'TXT' && name === 'ci2026._domainkey.build.example') return [[record]];
throw Object.assign(new Error('ENOTFOUND'), { code: 'ENOTFOUND' });
}
(async () => {
for (const f of process.argv.slice(2)) {
const res = await authenticate(fs.readFileSync(f), { mta: 'mx.h2check.test', resolver });
const line = res.headers.split(/\r?\n(?![ \t])/).find((h) => /^Authentication-Results:/i.test(h));
console.log(f.padEnd(12) + line.replace(/\r?\n\s+/g, ' ').match(/dkim=[^;]*/)[0]);
}
})();
Steps
- Step 1.
Sign one message twice, changing only the canonicalisation.
node mk.jsrelaxed.eml c=relaxed/relaxed h=from:to:subject:date:message-id:mime-version:content-type simple.eml c=simple/simple h=from:to:subject:date:message-id:mime-version:content-typemk.jsreadsbase.emlanddkim.private.pem, callssignonce per algorithm pair and writes the two files.X-Build-Numberis deliberately left out ofh=. - Step 2.
Verify the untouched message and read the two halves separately.
node v.js relaxed.emld : build.example s : ci2026 a : rsa-sha256 c : relaxed/relaxed h : from:to:subject:date:message-id:mime-version:content-type bodyHash : match signature : match result : passbodyHashcompares a hash of the canonicalised body againstbh=.signatureverifiesb=over the seven headers inh=plus the signature header itself withb=emptied. - Step 3.
Add one trailing space to one body line and verify both files again.
node touch1.jsrelaxed.eml c=relaxed/relaxed pass body hash match simple.eml c=simple/simple fail body hash mismatchOne byte, invisible in every mail client, and the two messages disagree.
relaxedbody canonicalisation deletes whitespace at the end of a line before hashing.simpledeletes nothing except empty lines at the very end of the body. - Step 4.
Run five changes against both canonicalisations and read the matrix.
node mutate.js== relaxed.eml c=relaxed/relaxed unmodified : pass trailing space on a body line: pass two spaces inside Subject : pass signed header re-folded : pass unsigned header changed : pass footer line added to body : fail [body hash] == simple.eml c=simple/simple unmodified : pass trailing space on a body line: fail [body hash] two spaces inside Subject : fail [header signature] signed header re-folded : fail [header signature] unsigned header changed : pass footer line added to body : fail [body hash]Four of the five changes are ones a person would call harmless. Under
relaxed/relaxedonly the added footer breaks the signature. Undersimple/simplefour of five break it. - Step 5.
Check the result against an implementation you did not write.
node xcheck.js relaxed.eml simple.emlrelaxed.eml dkim=pass header.i=@build.example header.s=ci2026 header.a=rsa-sha256 header.b=fjhLXXHe simple.eml dkim=pass header.i=@build.example header.s=ci2026 header.a=rsa-sha256 header.b=SKkatPd4A verifier that agrees with its own signer proves nothing.
xcheck.jshands the same two files tomailauth, with a stand-in resolver that answersci2026._domainkey.build.examplefromdkim.record.txt.
How to read the result
| What you see | What it means | What to do |
| --- | --- | --- |
| bodyHash: MISMATCH, signature: match | The body changed after signing | Look for a footer injector, a disclaimer appliance, or a list server. |
| bodyHash: match, signature: MISMATCH | A header in h= changed, or the key is wrong | Compare the record at <s>._domainkey.<d> with the key that signed. |
| Both mismatch | You are verifying against the wrong key | The selector rotated, or d= is not the domain you assumed. |
| l= present in the signature | Only the first n bytes of the body are signed | Anything appended after byte n passes unchecked. Treat the signature as partial. |
| c= absent | Defaults to simple/simple | The strictest pair, and the one most likely to break in transit. |
| Verification fails on a saved file that passed on arrival | Your mail client rewrote the message | Export the raw source again. Do not open it in an editor that normalises line endings. |
Common mistakes
What to check next
- How to check DKIM record: the key this procedure verifies against, and the reasons it can be unusable.
- How to check DKIM selector: where
s=comes from when you did not sign the message yourself. - How to check if an email has passed SPF DKIM and DMARC: the receiver's verdict on the same message.
- How to check DMARC alignment: why a passing signature can still leave DMARC failing.
- Email testing checklist: where this check sits in a release that touches mail.
FAQ
How to manually verify a DKIM signature?
Two hashes and one signature check, as the steps show. Canonicalise the body, hash it, compare with bh=. Canonicalise the headers in h= plus the signature header with b= emptied, and verify b= over them with the public key.
Why did DKIM signature verification fail?
Read which half failed first. A body hash mismatch points at something in the delivery path that changed the body. A header signature mismatch points at a changed signed header or the wrong key.
Does a mailing list break DKIM?
Usually, yes. Lists add a subject tag and a footer, and both are covered by the signature. Lists that care about this rewrite the From: header and sign as themselves, which is why list mail arrives with d= set to the list domain.
Can I verify a signature with openssl alone?
Only the last step. openssl verifies b= once you have built the exact byte sequence that was signed, and building it is the whole difficulty. The canonicalisation has to come from somewhere, which is what dkim.js supplies.
What does l= do to a signature?
It limits the body hash to the first n bytes. A relay can append anything after that point and the signature still verifies, so a signature carrying l= covers less than it appears to.
Verified
Verified by Maks Vernyopenssl 3.1.1node 22.23.2mailauth 5.0.3
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
advanced15 minpublished updated Maks Verny