"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":
Authentication-Results: mx.google.com;
dkim=fail reason="signature verification failed" [email protected] header.s=2026a;
spf=pass (google.com: domain of [email protected] designates 203.0.113.9 as permitted sender);
dmarc=fail (p=REJECT sp=REJECT dis=REJECT) header.from=example.comNine 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.
- `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: theDKIM-Signatureheader itself, minus its ownb=value, is the last header fed into the header hash. Sob=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.
Symptom-to-cause, at a glance:
| You observe | Which hash | Most likely cause |
|---|---|---|
bh mismatch, b otherwise fine | body | footer appended, CTE re-encoded, whitespace altered |
b fails, bh reported OK | headers/key | Subject rewritten, header added/reordered, selector rotated |
No key / key not found | b (DNS) | selector removed, TXT split wrong, truncated record |
dkim=pass but dmarc=fail | neither |
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:
c=relaxed/relaxedIf 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.Subjectis in nearly everyh=, sob=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: 8bittoquoted-printable. Every accented character becomes=C3=A9and friends. Total body rewrite →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:
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:
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:
opendkim-testmsg < message.eml # exit 0 = pass; non-zero prints the reason
dkimverify < message.eml # dkimpy: prints the precise failure reasondkimverify (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:
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-sha256with anrsa-sha256fallback 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=withopenssl dgst -sha256on the canonicalized received body. - 4.If
bhfails, `diff` received vs sent body — hunt the footer or the8bit→quoted-printablere-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
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.


