DKIM “Body Hash Did Not Verify”: Causes and How to Fix It
You open the headers of a test message and find dkim=neutral (body hash did not verify) or dkim=fail (body hash mismatch). Your DKIM key is published and the selector resolves, but the signature still fails. This error almost never points to a DNS problem. It means the message body the receiver got is different from the body your server signed. This guide explains what the body hash is, which tools and services commonly change messages in transit, and how to find the step that’s breaking your signature.
What the body hash actually is
A DKIM signature protects two things separately: the message body and a chosen set of headers. When your server signs a message, it does three things:
- It canonicalizes the body (normalizes it according to the
c=tag), hashes it, and stores the result in thebh=tag. - It hashes the signed headers plus the DKIM-Signature header itself (which includes
bh=). - It signs that header hash with the private key and stores the result in
b=.
A typical signature looks like this:
DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=example.com;
s=mail2026; h=from:to:subject:date:message-id:mime-version:content-type;
bh=2jUSOH9NhtVGCQWNr9BrIAPreKQjO6Sn7XIkfJVOzv8=;
b=dzdVyOfAKCdLXdJOc9G2q8LoXSlEniSbav+yuU4zGeeruD00lszZVoG4ZHRNiYzR...
The receiver checks the body first. It canonicalizes the body it received, hashes it, and compares the result with bh=. If they don’t match, verification stops right there with “body hash did not verify”. The receiver never gets as far as fetching your public key. Gmail reports this as dkim=neutral instead of fail, but the effect is the same: the signature doesn’t count towards DMARC.
Body hash error vs. signature error
The two errors have different causes, so it’s worth telling them apart:
- “body hash did not verify”: the body changed after signing. The key and DNS are fine.
- “signature did not verify” / “bad signature”: the body matches, but a signed header changed, or the public key in DNS doesn’t match the private key that signed the message (wrong selector, rotated key, or a truncated or split TXT record).
- “no key for signature” / “key not found”: the selector record
s._domainkey.ddoesn’t exist or doesn’t resolve.
Common causes
1. Footers and disclaimers added after signing
This is the most common cause by far. A legal disclaimer, an “external sender” banner, an unsubscribe footer or a tracking pixel gets added to the body after DKIM signing. Typical places this happens:
- A disclaimer milter or amavis/altermime running after OpenDKIM in your Postfix milter chain.
- An outbound security gateway (Mimecast, Proofpoint, Barracuda, Symantec) that sits downstream of the server that signs.
- Mailing list software (Mailman, Google Groups, Listserv) that adds a footer or a subject tag.
No canonicalization mode tolerates added content. relaxed only ignores whitespace differences.
2. Link rewriting and content scanning
Microsoft Defender Safe Links, Proofpoint URL Defense and similar products rewrite URLs in the body. On outbound mail this must happen before signing. On inbound mail it should happen after DKIM verification. If the gateway verifies DKIM again after rewriting, the check fails.
3. 8bit to quoted-printable conversion
This one is subtle. Your application sends a message with Content-Transfer-Encoding: 8bit (for example UTF-8 text with accents), and your MTA signs it. When the message is then relayed to a server that doesn’t advertise 8BITMIME, the MTA converts the body to quoted-printable. The content means the same thing, but the bytes are different, so the hash breaks. Postfix does this conversion by default, after the milter has signed.
4. Line length and line endings
RFC 5322 limits lines to 998 characters. Generated HTML emails often contain a single huge line. Some MTAs and gateways wrap those lines, which changes the body. Mixed bare LF and CRLF line endings, or trailing whitespace, can also break signatures that use simple body canonicalization.
5. Signing in the wrong place, or twice
Your ESP signs the message, then your own relay modifies it and doesn’t sign it again. Or a relay signs it a second time with the wrong d=. Either way, the only signature aligned with your domain ends up broken.
6. Forwarding
Plain forwarding (a .forward file or an alias) usually keeps DKIM intact. Forwarders that add a “Forwarded by…” note, or that reformat MIME parts, break it. You can’t control this, but you should recognize it in your DMARC reports (source IPs that belong to universities, ISPs or forwarding services).
How to diagnose it step by step
- Get the raw message. In Gmail use “Show original”, in Outlook use “View message source”. Save the complete source, including headers, as
message.eml. - Read the Authentication-Results header. Make sure the failure is on the signature with your
d=domain. There may be several signatures, and only one has to pass and align for DMARC. - Look at the Received chain. Read the
Received:headers from bottom to top and note every hop after your signing server. Each of those hops could have changed the body. - Verify locally with dkimpy. This shows whether the message fails as delivered:
pip install dkimpy dkimverify < message.eml # or, with details: python3 -c "import dkim,sys,logging; logging.basicConfig(level=logging.DEBUG); \ print(dkim.verify(open('message.eml','rb').read(), logger=logging.getLogger()))"The debug output shows the computed body hash next to the
bh=value. - Capture the message right after signing. Send a copy to a mailbox on the signing server itself, or BCC an address whose path skips the gateway. Diff that copy against the delivered version:
diff <(sed -n '/^$/,$p' signed.eml) <(sed -n '/^$/,$p' delivered.eml)The added footer, the rewritten links or the changed
Content-Transfer-Encodingwill show up immediately. - Test without content. Send a plain-text, ASCII-only message with no links. If that one passes and your HTML newsletter fails, the cause is encoding, line length or link rewriting.
Fixes
Sign last
DKIM signing has to be the very last step that touches the body. In Postfix, put the signing milter after any content filter that modifies the message:
# main.cf: disclaimer first, OpenDKIM last
smtpd_milters = inet:127.0.0.1:10030, inet:127.0.0.1:8891
non_smtpd_milters = $smtpd_milters
If an external gateway adds footers, either turn on DKIM signing on the gateway itself (most of them support it), or move the disclaimer to the server that signs.
Use relaxed/relaxed canonicalization
Set c=relaxed/relaxed. It tolerates whitespace changes and header re-folding that simple doesn’t. In OpenDKIM:
Canonicalization relaxed/relaxed
Don’t use the l= (body length) tag as a workaround. It lets anyone append content to your signed message, and some receivers treat it as suspicious.
Avoid 8bit conversion after signing
The cleanest fix is to have your application send 7bit-safe content: encode text parts as quoted-printable or base64 before the message reaches the MTA. Most mail libraries have an option for this. As an alternative, stop Postfix from converting messages after signing:
# main.cf
disable_mime_output_conversion = yes
Keep lines under 998 characters
Make sure your template engine or ESP wraps HTML, or uses quoted-printable encoding, which wraps lines at 76 characters automatically.
Rely on SPF alignment and ARC for forwarding and lists
You can’t stop third parties from changing your mail. Make sure your SPF is aligned so DMARC still passes on direct delivery. For mailing lists, ARC (Authenticated Received Chain) lets receivers like Gmail and Microsoft trust the original authentication result. Modern Mailman and Google Groups support ARC.
Why this matters for DMARC
DMARC passes if either SPF or DKIM passes and aligns with the From domain. On direct delivery, an aligned SPF pass can hide a broken DKIM signature. As soon as the message is forwarded, SPF fails too and the message gets quarantined or rejected under p=quarantine or p=reject. In your aggregate reports this shows up as a known sending source with dkim=fail but spf=pass. That pattern almost always means something is changing the body after signing, and it’s worth fixing before you tighten your policy.