← Back home

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.

ResultLikely fault areaFirst evidence
body hash did not verifyBody changed after signing, or the verifier did not receive the complete bodyFull raw source, MIME boundaries, c= and bh=
no key for signatureSelector DNS missing, stale, or queried at the wrong names=, d=, public TXT lookup
bad signature or signature verification failureSigned headers, key mismatch, or broader message alterationh=, selector key, untouched raw message
dkim=pass but dmarc=failDKIM signing domain does not align with visible Fromd= 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

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:

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:

  1. Plain text from an authenticated mail client through Docker Mailserver directly to a mailbox you control.
  2. A minimal HTML message through the same route.
  3. The real application’s normal MIME message.
  4. 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

Compact recovery checklist

  1. Download the complete original message and retain a private untouched copy.
  2. Match it to one Docker Mailserver queue ID and timestamp.
  3. Record d=, s=, c=, h=, and bh=.
  4. Query the exact selector through public DNS without exposing private material.
  5. Map every filter, milter, relay, footer, and rewrite after signing.
  6. Compare minimal direct mail with application and relay paths.
  7. Move signing after the final intended body transformation.
  8. Send one fresh message and require dkim=pass plus aligned dmarc=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.