dkim=fail (body hash did not verify) is not a generic instruction to replace a DNS record. It means the receiver canonicalized the delivered message body, calculated its hash, and did not get the value stored in the signature’s bh= tag. A footer, link wrapper, MIME conversion, security gateway, mailing list, or signature service may have changed the message after the DKIM signer committed to an earlier body.
The shortest reliable investigation follows one message across one failing route. Preserve the raw source at the signing boundary and at the receiver, identify the signer from d= and s=, read the c= canonicalization rule, and reproduce the smallest body change. The lab below proves four distinct states with a synthetic message: the original validates, harmless whitespace survives relaxed body canonicalization, a new footer invalidates the old signature, and the same footer validates when it is added before signing.
DKIM verification has more than one failure stage. The selector in s= and signing domain in d= tell the receiver where to obtain the public key. The bh= value commits to the canonicalized body. The b= value signs selected headers plus the body-hash field. Those stages lead to different repair owners.
| Receiver evidence | Boundary to investigate first | What it does not prove |
|---|---|---|
body hash did not verify |
Message body changed or was canonicalized differently after signing | That the selector is missing or the key must be replaced |
no key for signature or selector lookup failure |
DNS owner, selector name, record publication, resolver result | That a gateway modified the body |
| Body hash matches but signature fails | Signed headers, key pairing, header canonicalization, verifier input | That the body remained visually identical in an inbox |
| DKIM passes but DMARC fails | Alignment between a passing DKIM/SPF domain and visible From |
That DKIM itself is broken |
RFC 6376 defines the bh=, b=, c=, d= and s= fields and the verifier sequence. Google’s current DKIM troubleshooting guidance is equally direct about the exact error: when Authentication-Results says the body hash did not verify, check whether forwarding or an outbound gateway changed the message. A selector lookup that returns SERVFAIL belongs in a separate DNSSEC chain-of-trust investigation, not in the body-mutation path below.
DNS still deserves one bounded check. Query the selector shown in the failing message, confirm the receiver can retrieve one complete public-key record, and compare it with the key owned by the named signer. Voxfor’s DKIM selector-overlap guide covers that DNS and signer transition. Once the correct key is available and the exact failure is a body-hash mismatch, return to the message path instead of changing unrelated authentication records.
A rendered inbox hides the bytes DKIM evaluated. Ask for the original .eml or “show original” source, including MIME boundaries, Content-Transfer-Encoding, the complete DKIM-Signature, and every Received and Authentication-Results field. Redact addresses, message identifiers, tokens, customer content, and private relay names before sharing evidence.
Next, list body-changing components in order:
The signature should normally be created after the final body-changing component under the sender’s control. That does not mean every downstream forwarder will preserve it. Forwarding can keep DKIM valid when the body remains unchanged, while mailing lists commonly add footers or rebuild MIME. Voxfor’s explanation of SPF, SRS, ARC, and forwarding separates those effects. RFC 8617 defines ARC as a way to convey authentication assessments across intermediaries; it does not make an altered original DKIM signature cryptographically valid again.
Route-specific failures are especially valuable. If direct delivery validates and delivery through one gateway fails, compare those two raw bodies. If only HTML campaigns fail, compare MIME structure and transfer encoding with a plain-text control. If only replies or forwarded copies fail, identify the component introduced on that branch. Do not merge several routes into one test and then guess which hop changed the message.
The lab uses Debian 13, dkimpy 1.1.8, OpenSSL 3.5.6, a synthetic example.test identity, and one mode-0700 directory. It publishes nothing to DNS and sends no mail. A verifier callback supplies the generated public key only for lab._domainkey.example.test, isolating body behavior after the selector/key pair is known. Run it on a disposable authorized Linux shell with python3-dkim and openssl installed.
The first block refuses an existing path, creates a synthetic private key, builds a CRLF message, and installs guarded cleanup. Never copy a production DKIM private key into a troubleshooting bundle.
set -Eeuo pipefail
umask 077
LAB=/tmp/voxfor-dkim-body-hash-lab-120
case "$LAB" in
/tmp/voxfor-dkim-body-hash-lab-*) ;;
*) printf 'unsafe lab path\n' >&2; exit 70 ;;
esac
[[ ! -e "$LAB" ]]
mkdir -m 700 "$LAB"
cleanup() {
if [[ -d "$LAB" ]]; then
find "$LAB" -type f -name '*.private' -exec shred -u -- {} +
find "$LAB" -mindepth 1 -delete
rmdir "$LAB"
fi
}
trap cleanup EXIT
command -v openssl >/dev/null
python3 -c 'import dkim'
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 \
-out "$LAB/lab.private" 2>"$LAB/keygen.log"
openssl pkey -in "$LAB/lab.private" -pubout \
-out "$LAB/lab.public.pem" >/dev/null 2>&1
awk '!/-----/{printf "%s",$0} END{print ""}' \
"$LAB/lab.public.pem" >"$LAB/public.b64"
python3 - "$LAB" <<'PY'
from pathlib import Path
import sys
root = Path(sys.argv[1])
at = bytes([64])
sender = b"sender" + at + b"example.test"
receiver = b"receiver" + at + b"example.net"
message_id = b"body-path-120" + at + b"example.test"
message = (
b"From: Sender <" + sender + b">\r\n"
b"To: Receiver <" + receiver + b">\r\n"
b"Subject: DKIM body path lab\r\n"
b"Date: Mon, 10 Aug 2026 18:20:00 +0000\r\n"
b"Message-ID: <" + message_id + b">\r\n"
b"MIME-Version: 1.0\r\n"
b"Content-Type: text/plain; charset=utf-8\r\n"
b"Content-Transfer-Encoding: 7bit\r\n\r\n"
b"Invoice 1042 is ready.\r\n"
b"Use the customer portal to review it.\r\n"
)
(root / "original.eml").write_bytes(message)
PY
The helper below performs the same relaxed/relaxed signing and local verification for every variant. It also exposes the signature’s canonicalization and a short, non-secret prefix of bh=. The prefix is evidence for this synthetic run, not a value to compare with production mail.
cat >"$LAB/dkim_lab.py" <<'PY'
from pathlib import Path
import dkim, os, re, sys
root = Path(os.environ["LAB"])
record = b"v=DKIM1; k=rsa; p=" + (root / "public.b64").read_bytes().strip()
def dnsfunc(name, timeout=5):
expected = b"lab._domainkey.example.test"
return record if name.rstrip(b".") == expected else None
def sign(source, target):
message = Path(source).read_bytes()
key = (root / "lab.private").read_bytes()
signature = dkim.sign(
message, selector=b"lab", domain=b"example.test", privkey=key,
canonicalize=(b"relaxed", b"relaxed"),
include_headers=[b"from", b"to", b"subject", b"date", b"message-id",
b"mime-version", b"content-type", b"content-transfer-encoding"],
)
Path(target).write_bytes(signature + message)
def verify(path):
return dkim.verify(Path(path).read_bytes(), dnsfunc=dnsfunc)
action, source, target = sys.argv[1:4]
if action == "sign":
sign(source, target)
elif action == "whitespace":
data = Path(source).read_bytes().replace(
b"Invoice 1042 is ready.\r\nUse the customer portal to review it.\r\n",
b"Invoice 1042 is ready. \r\nUse the customer portal to review it.\t\r\n\r\n",
1,
)
Path(target).write_bytes(data)
elif action == "footer-after":
Path(target).write_bytes(
Path(source).read_bytes() + b"Confidentiality notice: internal recipients only.\r\n"
)
elif action == "footer-before":
unsigned = Path(source).read_bytes() + b"Confidentiality notice: internal recipients only.\r\n"
temporary = root / "final-unsigned.eml"
temporary.write_bytes(unsigned)
sign(temporary, target)
else:
raise SystemExit("unknown action")
if action == "sign":
header = Path(target).read_bytes().split(b"\r\n\r\n", 1)[0]
canon = re.search(br"\bc=([^;]+)", header).group(1).decode()
body_hash = re.search(br"\bbh=([^;\s]+)", header).group(1).decode()
print(f"canonicalization={canon}")
print(f"body_hash_prefix={body_hash[:16]}")
PY
export LAB
Create the baseline signature and require the untouched message to validate. This establishes that the synthetic key, selector, headers, body, and verifier agree before a mutation is introduced.
python3 "$LAB/dkim_lab.py" sign \
"$LAB/original.eml" "$LAB/signed-original.eml"
BASELINE=$(python3 - "$LAB" <<'PY'
from pathlib import Path
import dkim, sys
root = Path(sys.argv[1])
record = b"v=DKIM1; k=rsa; p=" + (root / "public.b64").read_bytes().strip()
def dnsfunc(name, timeout=5):
return record if name.rstrip(b".") == b"lab._domainkey.example.test" else None
print("valid" if dkim.verify((root / "signed-original.eml").read_bytes(), dnsfunc=dnsfunc) else "invalid")
PY
)
[[ "$BASELINE" == valid ]]
printf 'baseline=%s\n' "$BASELINE"
relaxed Body Canonicalization ToleratesRelaxed body canonicalization reduces runs of spaces or tabs inside a line, removes whitespace at line ends, and ignores empty lines at the end of the body. It does not ignore new words, changed URLs, rebuilt attachments, different MIME boundaries, or a conversion that changes the canonicalized content. Switching to relaxed/relaxed can prevent whitespace-only failures; it cannot authorize a footer added after signing.
The lab widens a space run, adds trailing whitespace, and adds empty lines. The signed body remains equivalent under the declared relaxed rule.
python3 "$LAB/dkim_lab.py" whitespace \
"$LAB/signed-original.eml" "$LAB/relaxed-whitespace.eml"
WHITESPACE=$(python3 - "$LAB" <<'PY'
from pathlib import Path
import dkim, sys
root = Path(sys.argv[1])
record = b"v=DKIM1; k=rsa; p=" + (root / "public.b64").read_bytes().strip()
def dnsfunc(name, timeout=5):
return record if name.rstrip(b".") == b"lab._domainkey.example.test" else None
print("valid" if dkim.verify((root / "relaxed-whitespace.eml").read_bytes(), dnsfunc=dnsfunc) else "invalid")
PY
)
[[ "$WHITESPACE" == valid ]]
printf 'relaxed_whitespace=%s\n' "$WHITESPACE"
Production mail may also change transfer encoding or line endings before signing. RFC 6376 recommends normalizing content to reduce transport conversions, but the practical test remains the raw source received on the failing route. A visually unchanged HTML message can have different MIME bytes, and a visually added footer can be obvious while still tempting operators into a DNS-only investigation.
Append one real line after the signed body. The public key, selector, signature header, and original content remain untouched; only the delivered body changes. Verification must now return invalid.
python3 "$LAB/dkim_lab.py" footer-after \
"$LAB/signed-original.eml" "$LAB/post-sign-footer.eml"
POST_SIGN=$(python3 - "$LAB" <<'PY'
from pathlib import Path
import dkim, sys
root = Path(sys.argv[1])
record = b"v=DKIM1; k=rsa; p=" + (root / "public.b64").read_bytes().strip()
def dnsfunc(name, timeout=5):
return record if name.rstrip(b".") == b"lab._domainkey.example.test" else None
print("valid" if dkim.verify((root / "post-sign-footer.eml").read_bytes(), dnsfunc=dnsfunc) else "invalid_expected")
PY
)
[[ "$POST_SIGN" == invalid_expected ]]
printf 'post_sign_footer=%s\n' "$POST_SIGN"
That result narrows the owner. Regenerating the same DNS key would produce the same mismatch because the receiver would still hash the altered body. Raising TLS requirements would protect transport confidentiality and peer authentication, but it would not stop an authorized gateway from adding a compliance notice. A bh= mismatch is evidence about signed content, not proof that an attacker modified mail in transit.
The repair is to make the body final before it is signed, or let the last body-changing outbound gateway become the authorized signer. In the control below, the same notice is added to the unsigned message first. A new signature then commits to that final body, and the verifier accepts it.
python3 "$LAB/dkim_lab.py" footer-before \
"$LAB/original.eml" "$LAB/footer-before-signing.eml"
FINAL=$(python3 - "$LAB" <<'PY'
from pathlib import Path
import dkim, re, sys
root = Path(sys.argv[1])
record = b"v=DKIM1; k=rsa; p=" + (root / "public.b64").read_bytes().strip()
def dnsfunc(name, timeout=5):
return record if name.rstrip(b".") == b"lab._domainkey.example.test" else None
signed = (root / "footer-before-signing.eml").read_bytes()
valid = dkim.verify(signed, dnsfunc=dnsfunc)
body_hash = re.search(br"\bbh=([^;\s]+)", signed.split(b"\r\n\r\n", 1)[0]).group(1).decode()
print("footer_before_signing=valid" if valid else "footer_before_signing=invalid")
print(f"final_body_hash_prefix={body_hash[:16]}")
PY
)
grep -qx 'footer_before_signing=valid' <<<"$(sed -n '1p' <<<"$FINAL")"
printf '%s\n' "$FINAL"
The reproduced run returned this compact receipt:
dkimpy=1.1.8
OpenSSL=3.5.6
canonicalization=relaxed/relaxed
baseline=valid
relaxed_whitespace=valid
post_sign_footer=invalid_expected
footer_before_signing=valid
signed_body_hash_prefix=EjklnFqQqEkhMoe0
final_body_hash_prefix=E3UvS02XHaBaW9EO
cleanup=lab_absent
The repair is demonstrated when the unmodified message validates, the whitespace-only variant still validates under relaxed body rules, the post-signing footer is rejected with the original key and signature, the footer-before-signing copy validates with a new bh= value, and the guarded lab directory no longer exists after cleanup.
One message may carry multiple DKIM signatures. Match the failing result to its header.d, selector, and identity, then decide whether another aligned signature already passes. DMARC evaluates aligned authentication, not the visual presence of a DKIM header. Aggregate reports can expose a route pattern, but they do not replace raw-message comparison for one body mutation.
Capture the selector DNS answer separately from the message bodies. DNS evidence establishes which public key the verifier could use. The outbound and delivered .eml files establish whether the body changed. If registrar, DNS host, application host and mail gateway belong to different teams, Voxfor’s domain and hosting ownership guide helps assign those boundaries before the incident record names an owner. Keep the DNS and body claims in separate rows.
Generate a unique harmless test message. Preserve it immediately after the signer and again at the failing receiver. Compare MIME parts after decoding transfer encoding only for human analysis, but also retain the original raw bytes because canonicalization operates on message syntax. Look for added footers, substituted links, rewritten HTML, boundary changes, converted character sets, attachment replacements, and new multipart wrappers.
Mail queues help preserve time and route evidence during a live incident. The Postfix queue triage workflow shows how to diagnose ownership before retrying; a blind retry can create a second message path or erase the easiest comparison point. SPF, TLS and storage results belong to their own evidence paths when the receiver names them; none substitutes for DKIM body evidence.
If an application adds tracking after signing, move signing later or move tracking earlier. If a compliance gateway must edit the body, configure that gateway to sign the final message with an authorized aligned domain. If a mailing list necessarily modifies messages, expect the original signature to fail and evaluate the list’s final signature plus ARC and local receiver policy. Never reuse or export the old private key merely to make two systems sign identically.
Test the repaired route with the same message shape that failed: same sender class, HTML/plain-text structure, attachment pattern, gateway, and receiver domain. A local verifier is a strong preflight, yet the acceptance result belongs at the real receiver because that is where the disputed body is canonicalized.
Remove the synthetic key and lab files only through the guarded path below. In production, rollback restores the previous mail-flow order or disables the new modifier; it does not reuse a private key on an untrusted hop, delete a working selector, or weaken DMARC merely to hide a failing signature.
cleanup
trap - EXIT
[[ ! -e "$LAB" ]]
printf 'cleanup=lab_absent\n'
bh= MismatchNot by itself. A missing, malformed, or mismatched selector record produces a key-discovery or signature-verification problem. body hash did not verify specifically says the delivered canonicalized body did not match bh=. Confirm the selector once, then compare the mail path and raw bodies.
relaxed/relaxed canonicalization fix every body change?No. Relaxed body canonicalization tolerates limited whitespace changes and trailing empty lines. It does not ignore a new disclaimer, rewritten URL, changed MIME boundary, converted attachment, inserted tracking pixel, or altered text. Sign the final body rather than treating canonicalization as permission to modify it later.
Yes. Forwarding that preserves the signed body can keep DKIM valid, but a forwarder or mailing list that adds content or rebuilds MIME can invalidate it. Compare direct and forwarded raw sources, then evaluate the final signer’s alignment and any trustworthy ARC chain separately.
Yes. DMARC can pass through another valid, aligned DKIM signature or valid aligned SPF. Read the complete Authentication-Results fields and alignment domains. Do not treat one failed third-party signature as the final DMARC verdict.
Whichever trusted component makes the last sender-controlled body change should normally sign the final result. The chosen signer must be authorized for the domain, protect its private key, preserve required headers, and produce a message that validates at representative receivers.
Only when evidence points to a key or selector mismatch. Replacing a correct key while a later gateway still changes the body adds risk without changing the failing causal path. Keep the key stable during the controlled body comparison so one variable changes at a time.
Keep the failing and repaired receiver results, the d=, s= and c= values, selector lookup, sanitized raw-source hashes at both boundaries, the exact modifying hop, the mail-flow change, and a representative receiver validation. That record lets another operator distinguish a real repair from a coincidental delivery.
A green DNS lookup and a successful local signature are prerequisites, not closure. The original incident happened after a particular sender, modifier, relay, and receiver processed one message shape. Recreate that shape, preserve its raw source at the signer and receiver, and require the receiver to report a valid aligned result after the mail-flow repair.
The decisive evidence is deliberately small: the final body is complete before signing, its bh= value matches what the receiver canonicalizes, and the post-signing route does not change that body. Once the formerly failing route proves those facts, the DKIM body-hash incident is closed without speculative DNS changes or weaker authentication policy.