2026.08 / DKIM troubleshooting 037
Fix DKIM “Body Hash Did Not Verify” in Docker Mailserver
A receiver reports dkim=fail (body hash did not verify), yet the selector exists in DNS and Docker Mailserver says it signed the message. That is not a contradiction. It normally means the receiver found the public key but calculated a different hash from the body it received.
The practical fault line is the DKIM signing boundary: what touched the message body after the signer calculated the bh= value? Work from one complete raw message and one queue ID. Do not rotate keys, loosen DMARC, or rewrite DNS until the evidence points there.
A body-hash mismatch is usually a message-integrity problem, not proof that the DKIM public key is wrong.
What the error actually says
A DKIM signature includes a body hash in bh=, a signing domain in d=, a selector in s=, and canonicalisation rules in c=. The sender canonicalises the body and hashes it before signing. The receiver repeats that process on the body it received. Different body bytes after canonicalisation produce a different hash.
| Result | Likely fault area | First evidence |
|---|---|---|
body hash did not verify | Body changed after signing, or the verifier did not receive the complete body | Full raw source, MIME boundaries, c= and bh= |
no key for signature | Selector DNS missing, stale, or queried at the wrong name | s=, d=, public TXT lookup |
bad signature or signature verification failure | Signed headers, key mismatch, or broader message alteration | h=, selector key, untouched raw message |
dkim=pass but dmarc=fail | DKIM signing domain does not align with visible From | d= versus RFC5322 From domain |
1. Preserve the complete raw message
In the recipient mailbox, download or show the original message—not a forwarded copy, screenshot, copied header block, or webmail rendering. DKIM body verification needs the complete body. A checker given headers only can correctly report that the body hash does not match because the body is absent.
Keep the original privately. Raw mail can contain addresses, message content, internal hostnames, delivery identifiers, and authentication details. Redact a copy before sharing it. On the sender, save the matching queue ID and a narrow log window:
docker compose logs --since 30m mailserver
# After identifying the queue ID, narrow the evidence
# Replace QUEUE_ID with the non-secret identifier from your own log
docker compose logs --since 30m mailserver | grep 'QUEUE_ID'
The queue ID ties together acceptance, filtering, DKIM signing, relay, and remote delivery. Without it, it is easy to compare a recipient’s failed message with logs from a later successful test.
2. Read the signature before changing anything
Find the DKIM-Signature header in the original source and record these tags:
DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed;
d=example.com; s=selector1; ...;
h=From:To:Subject:Date:Message-ID:MIME-Version:Content-Type;
bh=BASE64_BODY_HASH; b=BASE64_SIGNATURE
c=gives header/body canonicalisation.relaxed/relaxedtolerates limited whitespace normalisation; it does not tolerate disclaimers, rewritten links, removed MIME parts, or changed text.d=ands=identify the DNS key atselector1._domainkey.example.com.h=lists signed headers. A body mismatch specifically concernsbh=, but a later header rewrite can cause a separate signature failure.- An
l=body-length tag signs only a prefix. Do not add it as a casual workaround; it can leave appended content outside the signed body.
RFC 6376 defines both canonicalisation algorithms and the body-hash calculation. “Relaxed” means normalised, not mutable.
3. Confirm the receiver used the intended public key
A body mismatch often means key discovery already worked, but verify the exact selector anyway. Query public recursive DNS rather than trusting the DNS dashboard:
dig +short TXT selector1._domainkey.example.com @1.1.1.1
dig +short TXT selector1._domainkey.example.com @8.8.8.8
Replace the documentation domain with your own. Do not print or paste the private key. If the TXT record is absent, truncated by a bad DNS entry, or belongs to another key, fix that distinct problem first. If the receiver explicitly says the body hash differs after retrieving the key, keep investigating the message path.
4. Map every component after DKIM signing
Draw the actual route, not the route you assume:
application or mail client
→ authenticated submission
→ content filtering
→ DKIM signing
→ outbound Postfix transport
→ optional smarthost or security gateway
→ recipient
The signer should be the last component that intentionally changes the body. Common post-signing changes include:
- an outbound relay adding a legal footer or antivirus notice;
- a gateway rewriting tracked links or HTML;
- a second filter changing MIME transfer encoding, boundaries, or line endings;
- an application signing a message before a downstream service adds text;
- a mailing-list processor changing the subject and body;
- broken software truncating a MIME part or mishandling the final line ending.
Do not assume Docker Mailserver is the only signer. A message can carry multiple DKIM signatures. Identify which d=/s= pair failed and which component added it.
5. Check the Docker Mailserver signer and filter order
Current Docker Mailserver releases use Rspamd for DKIM signing; older deployments may use OpenDKIM. Check the documentation matching the image tag you actually run, then inspect process and log evidence without exposing keys:
# Identify the pinned image and running services
docker compose images mailserver
docker compose ps mailserver
# Read only recent DKIM/filter events
docker compose logs --since 30m mailserver | \
grep -Ei 'dkim|rspamd|opendkim|milter|queue'
# List likely DKIM files and permissions; never print key contents
docker compose exec mailserver sh -lc \
'find /tmp/docker-mailserver -type f -path "*dkim*" -printf "%p %m\n"'
You are looking for the point at which the signature is added and whether another milter, transport, relay, or application-stage rewrite follows it. Avoid copying an old OpenDKIM fix into a current Rspamd-based container. Avoid editing generated files inside the running container; make supported configuration changes in the mounted source and keep a rollback.
6. Isolate the altering hop with controlled tests
Send fresh, low-risk test messages through progressively more complete routes:
- Plain text from an authenticated mail client through Docker Mailserver directly to a mailbox you control.
- A minimal HTML message through the same route.
- The real application’s normal MIME message.
- The same message through any optional smarthost or outbound gateway.
Give every test a unique subject token and record its queue ID. If plain text passes but application HTML fails, inspect the application’s MIME construction and downstream HTML handling. If direct delivery passes but the smarthost route fails, compare the raw source before and after that relay. If every route fails, inspect local filter/signing order.
Do not repeatedly send to unrelated recipients. A small controlled mailbox set produces cleaner evidence and avoids reputation noise.
7. Fix the boundary, then send a completely new message
The durable rule is simple: make all intended body transformations happen before DKIM signing, or have the final trusted outbound system apply the authoritative signature after its last transformation. Disable an unnecessary footer or rewrite, reorder a supported filtering stage, or sign at the final relay. Do not “fix” integrity by removing DKIM.
After changing the path, send a new message. A message already signed and queued keeps its old signature. At the recipient, require all of the following:
Authentication-Results: receiver.example;
dkim=pass header.d=example.com header.s=selector1;
dmarc=pass header.from=example.com
Also confirm a remote 250 delivery response in the matching sender log. Acceptance, DKIM pass, DMARC alignment, and inbox placement are separate observations.
What not to do
- Do not rotate DKIM keys first. A new key cannot prevent a later footer or MIME rewrite from changing the body.
- Do not weaken DMARC to hide DKIM failure. DMARC policy controls receiver treatment; it does not repair message integrity.
- Do not test with a forwarded copy. Forwarding systems may alter the message and the copy may omit evidence.
- Do not paste private keys or complete raw mail into public tools. Use local analysis where possible and redact shared evidence.
- Do not treat relaxed canonicalisation as permission to mutate HTML. It only normalises specific whitespace differences.
- Do not change several hops at once. One controlled change and one new message makes the result attributable.
Compact recovery checklist
- Download the complete original message and retain a private untouched copy.
- Match it to one Docker Mailserver queue ID and timestamp.
- Record
d=,s=,c=,h=, andbh=. - Query the exact selector through public DNS without exposing private material.
- Map every filter, milter, relay, footer, and rewrite after signing.
- Compare minimal direct mail with application and relay paths.
- Move signing after the final intended body transformation.
- Send one fresh message and require
dkim=passplus aligneddmarc=pass.
For the underlying rules, use RFC 6376, especially the canonicalisation and body-hash sections. For implementation details, use Docker Mailserver’s current DKIM, DMARC, and SPF documentation and the documentation matching your pinned release.