Debugging DKIM body-hash and signature failures: the two hashes nobody reads separately
A "DKIM fail" is not one bug. A body-hash (bh=) mismatch means the content changed after signing; a signature (b=) mismatch means the headers or canonicalization contract broke. Here is how to tell them apart with openssl and dig, why mailing lists break bh= every single time, and why l= and ARC are fixes that quietly become traps.
EvilMail TeamJuly 19, 202612 min read
"DKIM fail" is useless until you know which hash broke
Here is the line that lands in your inbox at 2am, straight out of a Gmail "Show original":
Nine engineers out of ten read dkim=fail and start rotating keys. That is the wrong move, because a DKIM signature carries two independent cryptographic checks, and they fail for completely different reasons.
Debugging DKIM Body-Hash & Signature Failures (bh vs b) | evilmail.pro — EvilMail Blog
`bh=` — the body hash. A SHA-256 digest of the *canonicalized message body*. If a single byte of the body changes after signing, bh= no longer matches. Nothing else has to be wrong.
`b=` — the signature. An RSA or Ed25519 signature computed over the *canonicalized signed headers*. Critically, bh= is one of the things it covers: the DKIM-Signature header itself, minus its own b= value, is the last header fed into the header hash. So b= breaks when a signed header changes, when headers are reordered, when the selector or key can't be resolved, or when the canonicalization the verifier applied doesn't match what the signer used.
The consequence: the body can change and `b=` may still verify against the header set, while `bh=` fails. Or the body is pristine, bh= matches, and b= fails because a relay rewrote a Subject you signed. Same generic error string, opposite root cause, opposite fix.
selector removed, TXT split wrong, truncated record
dkim=pass but dmarc=fail
neither
alignment problem, not a hash problem
Canonicalization: the whitespace that silently kills you
DKIM never hashes raw bytes. It hashes a *canonicalized* form, and you choose the algorithm per message in c=header/body. There are two options on each side.
`simple/body` is unforgiving: the body must arrive verbatim, with the sole allowance that trailing empty lines collapse to a single CRLF. Add one trailing space to one line, convert a lone LF to CRLF, and the hash is gone.
`relaxed/body` is what survives the real world: it strips trailing whitespace on each line, collapses internal runs of whitespace to a single space, and ignores trailing empty lines. `relaxed/header` lowercases header field names, unfolds folded headers, and compresses whitespace.
This is why a message that wraps a long header, or a gateway that normalizes line endings, passes under relaxed and fails under simple. There is exactly one correct choice in 2026:
text
c=relaxed/relaxed
If you are still emitting simple/simple, you are signing a contract the first hop can break by breathing on it. Fix your signer before you debug anything else.
Mailing lists: the canonical body-hash killer
Mailing lists exist to modify mail, and every modification is a byte change to something DKIM signed. Mailman 3, Google Groups, and legacy LISTSERV all do some subset of:
Append a footer — the "You are subscribed as… / unsubscribe here" block. Body changed → bh= dead.
Rewrite the Subject with a [ListName] prefix. Subject is in nearly every h=, so b= dies too.
Inject `List-Id`, `List-Post`, `List-Unsubscribe`, `List-Help` headers. Harmless to b= unless you signed them — but a symptom you can grep for.
Re-encode the body from Content-Transfer-Encoding: 8bit to quoted-printable. Every accented character becomes =C3=A9 and friends. Total body rewrite → bh= dead.
MIME-wrap a single text/plain part into multipart/mixed. New boundaries, new structure, bh= dead.
Then the second-order disaster. The list resends from its own IP, so SPF now aligns to the list domain, not your `From:`. Your DKIM is already broken by the footer. DMARC requires that *at least one* of SPF or DKIM both pass *and* align to the From: domain. Neither does. If your domain publishes p=reject, every list message from your users bounces — the classic "our whole engineering team got kicked off the Kubernetes list" outage. That single failure mode is why lists adopted From-munging: rewriting From: to the list's own address and dropping the original into Reply-To.
Reproducing it: pull the two hashes apart
Stop guessing. Prove which byte moved. Save the raw message — headers and body, exactly as received — to message.eml, then read the DKIM-Signature tags: v version, a algorithm, c canon, d domain, s selector, h signed headers, bh body hash, b signature, and the dangerous l body-length.
First, confirm the key even resolves. A truncated or split TXT record is a top-three cause of b= failures:
bash
dig +short 2026a._domainkey.example.com TXT
# v=DKIM1; k=rsa; p=MIIBIjANBgkqhkiG9w0BAQEF... (2048-bit keys arrive as
# multiple 255-char quoted strings — the verifier concatenates them; any
# stray space or a missing chunk = "key not found" or a b= mismatch)
Now recompute the body hash yourself. Extract the body (everything after the first blank line), apply relaxed/body canonicalization — strip trailing whitespace per line, collapse internal whitespace runs, drop trailing empty lines, terminate with a single CRLF — into body.canon, then:
bash
openssl dgst -sha256 -binary body.canon | openssl base64
# compare the output against the bh= value in the DKIM-Signature.
# Different? The body changed in transit. Full stop.
Rather than hand-roll canonicalization, let a real verifier hand you the exact reason:
bash
opendkim-testmsg < message.eml # exit 0 = pass; non-zero prints the reason
dkimverify < message.eml # dkimpy: prints the precise failure reason
dkimverify (from dkimpy) states body hash mismatch versus signature verification failed in plain words — which is the fork in the road you needed. When bh= fails, prove the footer byte-for-byte by diffing the as-received body against a known-clean copy of the original:
bash
diff <(sed -n '/^$/,$p' original.eml) <(sed -n '/^$/,$p' message.eml)
# the added "> To unsubscribe..." lines are your body-hash killer
`l=` and ARC: the two "fixes" that are really tradeoffs
The body-length tag `l=` tells the verifier to hash only the first *N* octets of the body. Set it to the length at signing time and an appended footer lives *past* the signed region, so bh= still matches. Tempting. Also a security hole: anyone can append arbitrary content after byte *N* — an entire second HTML body, a phishing payload — and the signature still validates. Modern verifiers, Google included, treat l= as suspicious, and some ignore it. Do not use l=. It trades a deliverability paper-cut for a content-injection wound.
ARC (Authenticated Received Chain) is the honest answer. When an intermediary modifies a message, it seals the authentication results it *observed* into three headers — ARC-Authentication-Results (AAR), ARC-Message-Signature (AMS), and ARC-Seal (AS) — each stamped with an instance number i=1, 2, …. The final receiver can then see "DKIM passed *before* the list touched it" and choose to honor that. Gmail and Microsoft 365 do honor ARC. But it is a trust hint, not a guarantee — the receiver decides whether it trusts the sealer. For a p=reject domain you still need From-munging on the list side, or a dedicated subdomain-signing strategy, because ARC alone will not save you at every receiver.
Header oversigning and replay: the `b=` failures nobody explains
The b= side has its own failure family that has nothing to do with bodies.
Oversigning means listing a header more than once in h= (e.g. h=from:from:subject:subject:...). The second, empty slot means "I attest there is exactly one of these." If a relay injects a *second* From: or Subject:, the count no longer matches and verification fails — which is the point; it blocks header-injection spoofing. The trap: oversign a *volatile* header and a benign relay that legitimately adds one breaks your signature. Oversign From, Subject, Date, To, Content-Type — not much else.
DNS-side `b=` failures, in order of how often they bite:
Selector rotated and the old TXT deleted too early, before in-flight mail drained.
The 2048-bit key split incorrectly across 255-char strings, or with a stray space at a boundary.
1024-bit key — deprecated; use 2048-bit RSA, or ed25519-sha256 with an rsa-sha256 fallback selector for older verifiers.
Trailing whitespace in the published record.
Rotate cleanly: publish s=2026b, sign with it, keep 2026a live for a week, then remove it.
The checklist you actually run at 2am
1.Read Authentication-Results — is it `bh` or `b`? Do not proceed until you know.
2.dig +short SELECTOR._domainkey.DOMAIN TXT — key present, not truncated, not split-mangled.
3.Recompute bh= with openssl dgst -sha256 on the canonicalized received body.
4.If bh fails, `diff` received vs sent body — hunt the footer or the 8bit→quoted-printable re-encode.
5.Grep for a List-Id: header — a list in the path explains almost everything.
6.Confirm your signer emits `c=relaxed/relaxed`, never simple/simple.
7.If an intermediary is present, check for a valid ARC-Seal chain.
8.Verify h= does not oversign volatile headers you don't control.
9.Check DMARC alignment separately — dkim=pass with dmarc=fail is an alignment bug, not a hash bug.
Read the two hashes independently and DKIM stops being a coin flip. bh= is about bytes in the body; b= is about headers, keys, and the canonicalization contract. Prove which one moved before you touch a single DNS record.