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

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
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
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 };
// 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

  1. Step 1.

    Sign one message twice, changing only the canonicalisation.

    node mk.js
    
    relaxed.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-type

    mk.js reads base.eml and dkim.private.pem, calls sign once per algorithm pair and writes the two files. X-Build-Number is deliberately left out of h=.

  2. Step 2.

    Verify the untouched message and read the two halves separately.

    node v.js relaxed.eml
    
    d          : 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     : pass

    bodyHash compares a hash of the canonicalised body against bh=. signature verifies b= over the seven headers in h= plus the signature header itself with b= emptied.

  3. Step 3.

    Add one trailing space to one body line and verify both files again.

    node touch1.js
    
    relaxed.eml c=relaxed/relaxed  pass   body hash match
    simple.eml  c=simple/simple    fail   body hash mismatch

    One byte, invisible in every mail client, and the two messages disagree. relaxed body canonicalisation deletes whitespace at the end of a line before hashing. simple deletes nothing except empty lines at the very end of the body.

  4. 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/relaxed only the added footer breaks the signature. Under simple/simple four of five break it.

  5. Step 5.

    Check the result against an implementation you did not write.

    node xcheck.js relaxed.eml simple.eml
    
    relaxed.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=SKkatPd4

    A verifier that agrees with its own signer proves nothing. xcheck.js hands the same two files to mailauth, with a stand-in resolver that answers ci2026._domainkey.build.example from dkim.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

Sign: A signature that verified in the mailbox fails once the message is saved to a file.Cause: DKIM signs CRLF line endings. Saving through a client, an editor with LF normalisation, or a shell command such as sed rewrites them, and the body hash changes. Copy the raw bytes, and confirm with wc -c that the file is the size the client reported.
Sign: The same change breaks the signature on one domain's mail and not on another's.Cause: They signed with different canonicalisation. The matrix in step 4 shows the same trailing space passing under relaxed/relaxed and failing under simple/simple, from one signer and one key. Read c= before deciding whether the relay is at fault.
Sign: An attacker adds a header and the signature still verifies.Cause: DKIM signs the headers named in h= and nothing else. X-Build-Number is absent from h= in step 1, so changing it leaves both canonicalisations passing. A signer protects a header against addition only by naming it in h= more often than it appears, which is called oversigning.
Sign: Your own verifier agrees with your own signer and both are wrong.Cause: Signing and verifying with one canonicalisation routine hides a bug in it, because the two sides make the same mistake. Step 5 exists for that reason: an independent implementation reading the same bytes is the only cheap check on the code.

What to check next

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.

advanced15 minpublished updated Maks Verny