DKIM signs two things separately. The bh= tag is a hash of the canonicalized message body. The b= tag is a signature over selected headers *plus* the bh= value. A verifier recomputes the body hash first; if that does not match, it stops and reports dkim=fail (body hash did not verify) without even checking the header signature.
That error is therefore a very specific diagnosis, and a useful one: the key resolved, the syntax was fine, and the body was modified after signing. It is not a DNS problem and not a key problem. Contrast it with dkim=fail (no key for signature) or dkim=permerror, which are DNS-side and are covered in how to find your DKIM selector.
How do I prove it from the headers?
View the raw source of the failing message. You want the Authentication-Results line added by the receiving server and the DKIM-Signature header it was judging.
Authentication-Results: mx.example.net;
dkim=fail reason="body hash did not verify"
header.d=example.com header.s=selector1 header.b=Xk9pQ2mR;
spf=pass [email protected];
dmarc=fail (p=NONE) header.from=example.com
DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=example.com;
s=selector1; t=1756684800;
h=from:to:subject:date:message-id;
bh=47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=;
b=Xk9pQ2mR7vN4tY8wL3cE5aB1dF6gH0jK2lM4nO7pQ9rS...Three details make the diagnosis:
reason="body hash did not verify"— the body, not the headers, is the problem. Not every receiver includes a reason string, but Google, Microsoft, and most open-source MTAs do.header.d=andheader.s=tell you which signature failed. If the message carries several DKIM-Signature headers, only one may have failed.- The
Received:chain above the failingAuthentication-Resultsshows every hop the message took. Whatever modified the body is one of those hops.
Then run the control experiment: send the same message content directly from the same system to the same recipient domain, bypassing whatever intermediary is in the chain. If the direct send passes DKIM and the routed one fails, the intermediary is doing it. If both fail, the signing side is producing a hash that never matched — look at the sending path first. Paste both sets of headers into the email header analyzer to line up the hops.
Confirm the key itself is healthy while you are there, so you can rule it out entirely:
dig +short TXT selector1._domainkey.example.com
"v=DKIM1; k=rsa; p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA..."What actually modifies a body in transit?
Mailing lists
The single most common cause. Traditional list managers append an unsubscribe footer, prepend a [list-name] tag to the subject, or convert the message to plain text. Any of those breaks the body hash — or the header signature, if subject was in the signed h= list.
The correct fix is on the list side, not yours: the list should either leave the body alone, or use From: rewriting so it takes responsibility for the message under its own domain. ARC (Authenticated Received Chain) lets a downstream receiver see that the message authenticated before the list touched it, but it only helps if the receiver evaluates ARC and trusts that list — treat it as mitigation, not a guarantee. Practically, mailing lists are the reason a DMARC rollout leaves a residue of failures that never goes to zero; see p=none vs quarantine vs reject.
Security appliances and gateways
- Antivirus or DLP gateways that append a "scanned by" banner or a confidentiality notice.
- URL-rewriting link protection that replaces every href with a click-tracking wrapper — this rewrites the HTML part and always breaks the hash.
- Attachment sandboxing that strips or replaces an attachment with a stub, changing MIME part boundaries.
- Disclaimer-stamping appliances that add a legal footer on egress. If this sits *after* your signer, every outbound message is broken before it leaves.
Encoding and line-ending changes
A relay that re-encodes a body from quoted-printable to base64, or downgrades 8-bit content to 7-bit, changes the bytes being hashed. Older relays that do not support the 8BITMIME extension will do this legitimately. Line endings matter too: SMTP requires CRLF, and a relay that normalizes bare LF to CRLF — or the reverse — will change the hash.
Canonicalization is your defence. The c= tag has two halves, header and body:
| Canonicalization | Tolerates | Recommendation |
|---|---|---|
c=simple/simple | Almost nothing beyond trailing empty lines | Avoid — fragile in real routing |
c=relaxed/relaxed | Whitespace runs, header case, header unfolding, trailing whitespace on body lines | Use this. It is what nearly all modern signers default to |
c=relaxed/simple | Header variation only | Rare; no reason to prefer it |
Relaxed body canonicalization ignores whitespace differences at the end of lines and collapses runs of whitespace, which absorbs the most common benign rewrites. It does not save you from an appended footer — nothing does.
The l= tag
The optional l= tag says "only the first N octets of the body are covered by this hash". It was designed exactly for the mailing-list footer problem, and it is a bad trade.
DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=example.com;
s=selector1; l=1234;
h=from:to:subject; bh=...; b=...With l= present, anyone can append arbitrary content past the signed length and the signature still verifies. In an HTML message that means an attacker can add whole visible sections to a message that authenticates as yours. Some receivers ignore l= outright or treat its presence as a negative signal. If your signer sets it, turn it off. If you inherited a configuration with l=, that is a finding, not a workaround.
Forwarding
Plain forwarding is usually fine for DKIM — it is SPF that breaks, because the forwarder is not in your SPF record. That asymmetry is why aligned DKIM is what keeps forwarded mail delivering under an enforcing DMARC policy. But a forwarder that also filters, rewrites links, or adds a banner breaks DKIM too, and then nothing survives the hop. Check what your SPF says with the SPF checker and confirm the alignment picture with the DMARC checker.
A working checklist
- Confirm the reason string really is "body hash did not verify" and not a key or syntax failure.
- Check whether the failure is universal or path-specific. Send to two receivers, one direct and one through the suspect route.
- Read the
Received:chain on the failing message and list every hop that is not you and not the final receiver. - Identify anything in your own outbound path that appends or rewrites content, and confirm it runs before the signer.
- Switch the signer to
c=relaxed/relaxedif it is not there already. - Remove
l=from the signing configuration if present. - If the modifying hop is a mailing list you do not control, accept it, and make sure your DMARC rollout accounts for that residue rather than trying to eliminate it.
- Verify the key still resolves with the DKIM checker and the whole picture with the deliverability test.