emailverifier.dev
Posts

DKIM Body Hash Mismatch: How Canonicalization Changes bh=

| 7 min read | Usama Ejaz
Two DKIM body-canonicalization paths show whitespace changes producing different hashes under simple mode, matching under relaxed mode, and changing after a footer.

A DKIM body hash did not verify error means the receiver hashed the canonicalized message body and got a value that does not match bh= in the DKIM-Signature header. It does not, by itself, mean the selector or public key is wrong.

The practical question is where the body changed. Start with the raw message at the receiver, read the body algorithm from c=, reproduce the canonicalization, and compare the result with bh=. If your recomputed value matches the receiver but not the signature, a hop changed the signed body after signing.

That distinction matters because a message can look identical in an inbox while its signed bytes differ. A gateway can add a footer, rewrite MIME boundaries, change transfer encoding, or touch whitespace. DKIM hashes octets after a narrow normalization step, not the rendered message a person sees.

Read the failure from four DKIM fields

This shortened header is hypothetical:

DKIM-Signature: v=1; a=rsa-sha256;
 c=relaxed/relaxed; d=example.test; s=mail2026;
 h=from:to:subject:date:message-id;
 bh=MitnvLiBcW0vUK7x3mD5X9eAZosudvRTSRBtlXeycnI=;
 b=BASE64_SIGNATURE_VALUE

Four fields divide the work:

  • a=rsa-sha256 selects the signing and hash algorithm. RFC 8301 requires RSA DKIM signers to use rsa-sha256, not rsa-sha1.
  • c=relaxed/relaxed selects header canonicalization first and body canonicalization second. If c= is absent, the default is simple/simple. If it contains one name, the body side still defaults to simple.
  • bh= is the base64-encoded hash of the canonicalized body.
  • b= is the signature over selected headers and the body hash. It is not a second copy of the body hash.

RFC 6376, section 3.7 defines that sequence. The verifier canonicalizes the body using the body half of c=, hashes it using the algorithm named by a=, and compares that value with bh=. If they differ, the entire DKIM signature fails.

This is separate from selector lookup. A missing or malformed key record points you toward s= and d=. My DKIM selector and key-rotation guide covers that branch. A body-hash mismatch points first toward message transformation.

Run the body through the same canonicalizer

simple and relaxed do much less than their names suggest.

Simple body canonicalization removes empty lines at the end, ensures the body ends with one CRLF, and changes nothing else. Spaces and tabs remain significant.

Relaxed body canonicalization also removes trailing empty lines. Before that, it removes spaces and tabs at the end of each line and turns each run of spaces or tabs inside a line into one ordinary space. It does not ignore new text, HTML changes, altered MIME boundaries, or rewritten encodings.

I ran this compact Node.js fixture against fixed CRLF input. It implements only body canonicalization and SHA-256 hashing, so it is a diagnostic lab rather than a full DKIM verifier.

import { createHash } from "node:crypto";

function canonicalizeBody(body, mode) {
  if (/(?<!\r)\n|\r(?!\n)/.test(body)) {
    throw new Error("Input must use CRLF line endings");
  }

  let lines = body.split("\r\n");

  if (mode === "relaxed") {
    lines = lines.map((line) =>
      line.replace(/[ \t]+/g, " ").replace(/[ \t]+$/g, "")
    );
  } else if (mode !== "simple") {
    throw new Error(`Unsupported mode: ${mode}`);
  }

  while (lines.at(-1) === "") lines.pop();

  if (lines.length === 0) {
    return mode === "simple" ? "\r\n" : "";
  }

  return `${lines.join("\r\n")}\r\n`;
}

function bodyHash(body, mode) {
  return createHash("sha256")
    .update(Buffer.from(canonicalizeBody(body, mode), "utf8"))
    .digest("base64");
}

The fixture compared this original body with a whitespace-normalized copy, then with a footer appended. The dots and arrows below make spaces and CRLF visible:

original body:
Hello··world·⇥·↵
Second⇥line⇥↵
↵

relaxed canonical body:
Hello·world↵
Second·line↵

The executed output was:

simple/original:    8rVE7nuquhXCEXfnBbQDA8kP3yWi6LX9hhVO9f2GZ2Q=
simple/normalized:  MitnvLiBcW0vUK7x3mD5X9eAZosudvRTSRBtlXeycnI=
relaxed/original:   MitnvLiBcW0vUK7x3mD5X9eAZosudvRTSRBtlXeycnI=
relaxed/normalized: MitnvLiBcW0vUK7x3mD5X9eAZosudvRTSRBtlXeycnI=
relaxed/footer:     DHNfGOVSBSZz1O9NN3aFJBU5FKslL0OwefR1BqUMShk=

5/5 checks passed

The whitespace-only edit changed the simple hash but not the relaxed hash. Adding the word Footer changed both. The two remaining tests checked the empty-body SHA-256 values published in RFC 6376, including the subtle difference that an empty simple body becomes one CRLF while an empty relaxed body becomes a zero-length input.

If you adapt the fixture, feed it the decoded body octets from a saved raw message. Do not copy visible text from an email client. The MIME transfer encoding, boundaries, and line endings are part of what DKIM processes. The DKIM specification treats the body as an octet string and gives MIME no special exemption, so attachments are included too.

Use the mismatch to locate the modifying hop

Preserve two raw copies if you control the sender: one immediately after the DKIM signer and one as received. Strip neither headers nor MIME structure. The useful comparison is between those wire representations, not between two rendered previews.

If relaxed passes but simple fails

The difference is limited to whitespace that relaxed canonicalization removes or compresses, plus trailing empty lines. Check transport libraries, template renderers, and gateways that rewrap or trim lines. This is exactly the kind of harmless-looking change relaxed mode is designed to tolerate.

If both modes fail after a footer appears

A security gateway, mailing list, or compliance system probably changed content after the message was signed. Switching to relaxed will not help because relaxed does not discard added text or HTML.

The clean fix is to make the final content change before signing. If a downstream gateway is responsible for the final message, it can add its content and then apply its own DKIM signature under a domain it is authorized to use. Existing signatures may still fail, but the new signature accurately covers the message that leaves that administrative hop.

If the visible message is unchanged but both modes fail

Compare MIME and encoding details. A system can re-encode quoted-printable or base64, normalize character encoding, change a multipart boundary, or convert line endings without changing what the inbox renders. DKIM sees those byte changes.

This often appears with indirect mail flows. Forwarders that leave the body alone can preserve DKIM, while mailing lists and gateways that modify content can break it. The email forwarding guide separates that DKIM behavior from SPF, SRS, and ARC.

Also identify which signature failed. A message can carry several DKIM signatures, and one may survive while another does not. Read each signature's d=, s=, c=, and result together. Do not combine fields from different signatures during diagnosis.

Fix the modifying hop, not the symptom

A fair objection is: why not use relaxed/relaxed everywhere and stop worrying about body changes? Relaxed canonicalization is useful, but its tolerance is deliberately narrow. It absorbs certain whitespace differences. It does not authorize arbitrary rewriting, and it cannot make a footer or changed MIME part hash to the old value.

The l= body-length tag is not a safe shortcut either. It limits how many body bytes DKIM validates, leaving later bytes unsigned. RFC 6376 warns that this can allow fraudulent content to be displayed without an appropriate warning.

I would fix the pipeline in this order: complete templates and tracking substitutions, build the final MIME message, apply required gateway transformations, then sign the exact message that will leave the responsible domain. Store enough raw-source evidence around each handoff to compare a failed sample later.

After the change, send a new message through the full path. Save the raw received source, recompute its canonicalized body hash, and confirm that it equals bh=. Then confirm the trusted receiver reports DKIM pass for the intended d=.

That DKIM pass proves the covered content survived from the signer. It does not prove the sender is safe, the mailbox exists, or the message belongs in the inbox. DMARC still evaluates identifier alignment, and receivers still apply reputation and content policy. The SPF, DKIM, and DMARC guide shows where that body-integrity result fits in the larger authentication decision.

Continue reading