Short answer. A DKIM signature covers two things: a hash of the message body (bh=) and a signature over
selected headers plus that hash (b=). body hash did not verify means the body changed after signing, for
example a footer, a disclaimer, an antivirus banner, or a re-encoding. bad signature / signature verification failed means a signed header changed, such as a Subject tag or a rewritten From, or the key does
not match. no key / key not found and permerror point to DNS: wrong selector, revoked or malformed
record. Relaxed canonicalization (c=relaxed/relaxed) tolerates changes in whitespace and in the case of header
names, and nothing else. Diagnose by comparing the Authentication-Results header with the DKIM-Signature tags, then fix the component that
modifies mail after signing, or sign later in the pipeline.
What DKIM actually signs
DKIM (RFC 6376) adds a DKIM-Signature
header. This one was produced by dkimpy 1.1.8 for a test message:
DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=example.com;
i=@example.com; q=dns/txt; s=sel; t=1792500885; h=from : to : subject
: date : message-id; bh=eMSXagBrSE6c3/J0njUAW7nGMOlyd4roWBYqgR3grrE=;
b=FVDVEZSc7Whs7waF1W5ImDSxpa/czkhdpeUBefQ3/daFclzCdP0x9EQh6NOxBCa6oWph4
WtntuKENSuqQGtjzcVlLdT+itgDCj5PaKMHI6S6JmQPBo2yy2EzPpdcDPeuiORtNbJMRHdZ
0MhMgg7k11sPD9ts0pE6i+nzWmUXyJ2jEKWw68oG59kSO3B9jns
The signature value is shortened here. The tags that matter for debugging:
| Tag | Meaning | Typical failure |
|---|---|---|
d=, s= | Signing domain and selector. The key is fetched from s._domainkey.d | Wrong selector, missing or revoked record |
c= | Header/body canonicalization, simple or relaxed. Default simple/simple | Whitespace changes break simple |
h= | Signed header fields, in order | Any change to one of them |
bh= | Base64 hash of the canonicalized body | Body changed |
b= | Signature over the signed headers and the DKIM-Signature itself (with b= empty) | Header changed, or wrong key |
l= | Optional: only the first l bytes of the body are hashed | Lets anyone append content (see below) |
a= | Algorithm: rsa-sha256 or ed25519-sha256 | rsa-sha1 is no longer valid (RFC 8301) |
The verifier checks in a fixed order. First it fetches the key (s._domainkey.d, TXT). Then it
canonicalizes the body and compares its hash with bh=. Only then does it verify b= over the headers.
That order explains the verdicts: a body change is reported as a body hash problem even if a header changed
too.
Canonicalization: what "relaxed" forgives
Canonicalization turns the message into a standard form before hashing, so harmless changes in transit do not break the signature. RFC 6376 §3.4 defines two forms:
- simple headers: no changes at all. Not even the header name's case may change.
- relaxed headers: lowercase the names, unfold continuation lines, reduce runs of whitespace to one space, and delete whitespace at the end of the value and around the colon.
- simple body: only trailing empty lines are ignored.
- relaxed body: in addition, whitespace at the end of lines is ignored and runs of spaces and tabs within a line become one space.
Neither form tolerates changed words, added lines, re-encoded MIME parts or different line endings. relaxed
only forgives whitespace and the case of header names. That is why c=relaxed/relaxed is the usual choice. The RFC default is
simple/simple, so a DKIM-Signature without c= uses the strict form.
The failures, reproduced
The test signs a short message with a 2048-bit RSA key (c=relaxed/relaxed, h=from:to:subject:date:message-id),
changes it the way real intermediaries do, and verifies it with dkimpy
1.1.8. The DNS lookup is replaced by an in-process function, so the key record can be changed between cases:
--- 1. unchanged message, relaxed/relaxed
log: bh: b'eMSXagBrSE6c3/J0njUAW7nGMOlyd4roWBYqgR3grrE='
log: b'DKIM-Signature' valid: True
verify: True
--- 2. list appends a footer to the body
log: bh: b'cL30gL2flFNCvVcW5T6gPIkNZAhq6yYRaXnavNmMAJ8='
exception: ValidationError: body hash mismatch (got b'cL30gL2flFNCvVcW5T6gPIkNZAhq6yYRaXnavNmMAJ8=', expected b'eMSXagBrSE6c3/J0njUAW7nGMOlyd4roWBYqgR3grrE=')
--- 3. list adds a [team] tag to the Subject
log: bh: b'eMSXagBrSE6c3/J0njUAW7nGMOlyd4roWBYqgR3grrE='
log: b'DKIM-Signature' valid: False
verify: False
--- 4a. whitespace changed in transit, relaxed/relaxed
log: bh: b'eMSXagBrSE6c3/J0njUAW7nGMOlyd4roWBYqgR3grrE='
log: b'DKIM-Signature' valid: True
verify: True
--- 4b. same whitespace change, simple/simple
log: bh: b'CdTP1x6zZ/fF6DwHq354s/aG/L7f72HAMBorT94XVmk='
exception: ValidationError: body hash mismatch (got b'CdTP1x6zZ/fF6DwHq354s/aG/L7f72HAMBorT94XVmk=', expected b'bxvAnpxX30DaKPhGagkY2hEaFkHH+Q8kAXms32JaSNY=')
--- 4c. simple/simple, only the Subject whitespace changed
log: bh: b'bxvAnpxX30DaKPhGagkY2hEaFkHH+Q8kAXms32JaSNY='
log: b'DKIM-Signature' valid: False
verify: False
Case by case:
- Unchanged message. The body hash matches, so the signature verifies.
- Footer appended. A mailing list adds an unsubscribe footer. The computed body hash (
got) differs frombh=(expected), so verification stops before it reaches the signature. - Subject tag. The list prefixes
[team]. The body hash still matches, so the body is intact, but the header signature fails. "Body hash OK, signature not OK" points at a changed header or a wrong key. - Whitespace. The test doubles a space in the Subject, collapses a double space in the body and adds a
trailing space. With
relaxed/relaxedthe signature still verifies. Withsimple/simplethe body change fails the body hash (4b), and the header change alone fails the signature (4c).
Receiving servers write the result into Authentication-Results. Here are the same cases verified by
mailauth 8.0.1 (Node.js 24.22), the format you will see in a
received message:
unchanged dkim=pass header.i=@example.com header.s=sel header.a=rsa-sha256 header.b=P1HuE+RF;
footer appended dkim=neutral (body hash did not verify) header.i=@example.com header.s=sel header.a=rsa-sha256 header.b=P1HuE+RF;
[team] added to Subject dkim=fail (bad signature) header.i=@example.com header.s=sel header.a=rsa-sha256 header.b=P1HuE+RF;
key revoked (empty p=) dkim=neutral (invalid public key) header.i=@example.com header.s=sel header.a=rsa-sha256 header.b=P1HuE+RF;
no key in DNS dkim=neutral (no key) header.i=@example.com header.s=sel header.a=rsa-sha256 header.b=P1HuE+RF;
Verifiers word results differently. mailauth reports a body hash mismatch as neutral by default and as
dkim=fail (body hash did not verify) in strict mode. OpenDKIM writes
dkim=fail reason="signature verification failed" for both body and header changes. The cause is the same. The error pages for
body hash did not verify,
signature verification failed,
key not found in DNS and permerror
list the variants.
Who changes messages in transit
Matching the symptom to a cause:
| Symptom | Usual culprit |
|---|---|
| Body hash mismatch on every message from one sender | A content filter, disclaimer or signature-appending tool running after the DKIM signer, often on the sender's own side |
| Body hash mismatch only for messages via a mailing list | The list software adds a footer or rewrites MIME parts |
| Body hash mismatch only for messages with non-ASCII text | 8-bit content converted to quoted-printable by a relay. RFC 6376 §5.3 tells signers to convert to 7-bit MIME before signing |
| Body hash mismatch for messages generated by a script | Bare LF line endings signed, CRLF on the wire, or the other way round |
| Bad signature, body hash OK | Subject tag, From rewriting (a list's DMARC mitigation), a gateway re-folding or re-encoding a header, Reply-To or To changed by forwarding |
| Bad signature on every message | Private key and DNS record don't match: wrong selector, a rotated key, or a key pasted with an error |
Forwarding by a plain .forward or alias usually keeps the body and the signed headers, so DKIM survives where
SPF breaks. Mailing lists are the classic breaker because they want to change the message. Modern list
software can avoid this by not modifying the body and Subject, or by rewriting From to the list's domain and
signing with its own key. Receivers that support ARC (RFC 8617,
experimental) can also trust a list's ARC chain, which records the original DKIM result before the
modification.
The l= tag: robustness that invites forgery
l= tells verifiers to hash only the first l bytes of the canonicalized body. It was meant to let signatures
survive mailing-list footers. The test shows the downside. The same message signed with l=35 still verifies
after content is appended, whether it is a footer or a whole new paragraph from someone else:
--- 5. signed with l= (body length limit)
l=35
--- 5a. footer appended to an l= signature
log: bh: b'eMSXagBrSE6c3/J0njUAW7nGMOlyd4roWBYqgR3grrE='
log: b'DKIM-Signature' valid: True
verify: True
--- 5b. a whole new part appended to an l= signature
log: bh: b'eMSXagBrSE6c3/J0njUAW7nGMOlyd4roWBYqgR3grrE='
log: b'DKIM-Signature' valid: True
verify: True
The appended text in 5b was Please pay invoice 4711 to IBAN XX00 0000 0000., and the signature still says
pass. RFC 6376 §8.2 warns that l= "enables attacks
in which an intermediary with malicious intent can modify a message", that "Signers should be extremely wary of
using this tag", and that "Assessors might wish to ignore signatures that use the tag". With MIME and HTML,
appended content can replace what the reader sees completely. Don't sign with l=. If you find it in
your outgoing mail, remove it from the signer's configuration. In OpenDKIM, l= comes from the
BodyLengthDB and MaximumSignedBytes settings.
Keys and DNS
The verifier looks up selector._domainkey.domain as a TXT record. The cases from the demo, then real DNS:
--- 6. key revoked in DNS (empty p=)
log: could not parse RSA public key (b''): Unparsable public key; SubjectPublicKeyInfo: Data truncated at byte 0; RSAPublicKey: Data truncated at byte 0
verify: False
--- 7. no key record in DNS
log: missing public key: b'sel._domainkey.example.com.'
verify: False
--- 8. 512-bit RSA key
log: bh: b'eMSXagBrSE6c3/J0njUAW7nGMOlyd4roWBYqgR3grrE='
log: b'DKIM-Signature' valid: True
exception: KeyFormatError: public key too small: 512
An empty p= is how a domain revokes a selector. Case 8 shows that the RSA arithmetic of a 512-bit
signature checks out, but the verifier rejects the key anyway. RFC 8301
requires that verifiers "MUST NOT consider signatures using RSA keys of less than 1024 bits as valid
signatures". Signers "MUST use RSA keys of at least 1024 bits" and "SHOULD use RSA keys of at least 2048 bits",
and rsa-sha1 "MUST NOT be used for signing or verifying". Ed25519 (a=ed25519-sha256,
RFC 8463) is also defined. The RFC's own example message carries
both an RSA and an Ed25519 signature, and that is how it is deployed, with the RSA signature for verifiers that
lack Ed25519.
In real DNS, OpenDKIM's opendkim-testkey checks a selector the way a verifier would:
$ opendkim-testkey -d github.com -s google -vvv
opendkim-testkey: checking key 'google._domainkey.github.com'
opendkim-testkey: key not secure
opendkim-testkey: key OK
$ opendkim-testkey -d gmail.com -s 20230601 -vvv
opendkim-testkey: checking key '20230601._domainkey.gmail.com'
opendkim-testkey: Revoked key
$ opendkim-testkey -d gmail.com -s nosuchselector -vvv
opendkim-testkey: checking key 'nosuchselector._domainkey.gmail.com'
opendkim-testkey: 'nosuchselector._domainkey.gmail.com' record not found
"key not secure" only means the answer was not DNSSEC-validated. The 20230601 selector at gmail.com is a
real retired key with an empty p=. Messages signed with it fail now, which is why old messages can "lose"
their DKIM pass after a key rotation.
A 2048-bit key does not fit into one 255-byte TXT string, so it is published as several strings that verifiers join without separators:
"v=DKIM1; k=rsa; p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAj6T5sl/RwdSqGoYWaWaFbS2UAeyPrEmd0gogocmRfS441qwR8/0KB81Hw89P0l4YiFRrXYk7NVIGfyCRHAYYZUzCkGeOysI2EjgzLFhd/NEsbRzOEc/kWkK/RO6JFq/5lOn6M9AZw/ap9tds4JG9ApgNNdSpPxp9DmvpsOSgNMVflRxQFrk3kdS4RNAPKu/" "OPoA7dlR/A/pECryjRoYgENtDXzdnK70HgCekems6UDzxDj61cjyoKoXtEMF/QsaHEQ1Gjfv014rDJBsubk/kT5VqHkWHa/ia68Z5r228Ety/wFfQNjXTx/J7KGZ9GkZlKED659eiJcLnWcKDSiQlhwIDAQAB"
Typical DNS mistakes: quotes or a space pasted into the key when splitting it by hand. A DNS provider that
turns one long string into two records instead of one record with two strings. The key published under the
wrong name, such as sel._domainkey.example.com.example.com when the zone appends the origin. And a new
selector used before the record has propagated. The
DKIM record generator produces a correctly split record from a
public key.
Step-by-step diagnosis
- Get the raw message as received, with all headers: "Show original" in Gmail, "View source" in
Outlook, or the
.emlfile. Forwarding the message to yourself changes it. - Read
Authentication-Resultsfrom your receiving server, the topmost one. Note the DKIM verdict and its reason: body hash, bad signature, no key, permerror. - Read the
DKIM-Signature:d=ands=(which key),c=(how strict),h=(which headers),l=(should be absent). - Check the key:
dig +short TXT s._domainkey.doropendkim-testkey -d d -s s -vvv. Is it there, not revoked, at least 1024 bits, and complete? - Body hash mismatch: look for what was added or re-encoded: footers, banners,
Content-Transfer-Encodingchanges between what was sent and what was received. If you control the sender, compare the copy in the Sent folder or MTA log with the received copy. - Bad signature with a good body hash: compare each header listed in
h=between sent and received copies. If none changed, the key does not match the private key in use. Test with a message you sign and verify locally. - Intermittent failures: look for multiple outbound paths (two MTAs, a cloud relay) where only one signs correctly, or a rotated selector still used by one of them.
To do this with a pasted message instead of by hand, the DKIM debugger
shows each canonicalization step, the computed bh= against the header's value, the key from DNS, and the
most likely modification. The message stays in your browser. For the whole picture, including SPF, DMARC and
ARC, use the email authentication analyzer.
Fixes that last
- Sign last. On your own outbound path, run disclaimers, footers, link rewriting and antivirus before the
DKIM signer. In Postfix that is the order of
smtpd_miltersorcontent_filter. - Use
c=relaxed/relaxed. It costs nothing and survives header re-folding and whitespace changes. - Convert to 7-bit MIME before signing (quoted-printable or base64), as RFC 6376 §5.3 advises, so relays without 8BITMIME support don't re-encode the body.
- Don't use
l=, and don't put headers that intermediaries legitimately change intoh=unless you need them signed. - Rotate keys with overlap. Publish the new selector, wait for DNS caches to expire, switch signing, keep
the old record for a few days so mail in transit still verifies, then revoke it with an empty
p=. - 2048-bit RSA keys. 1024 is the minimum. Add an Ed25519 signature alongside if your signer supports it.
- For mailing lists you run: don't modify the body and Subject, or rewrite
Fromand sign with the list's domain, and add ARC signatures.
Sources
- RFC 6376: DKIM Signatures, §3.4 (canonicalization), §3.5 (tags),
§5.3 (transport conversions), §8.2 (
l=). - RFC 8301: cryptographic algorithm and key usage update (no SHA-1, 1024-bit minimum).
- RFC 8463: Ed25519 for DKIM.
- RFC 8617: Authenticated Received Chain (experimental).
- dkimpy 1.1.8, mailauth 8.0.1,
OpenDKIM
opendkim-testkey.
The dkimpy cases ran in node:24.22.0-trixie (Debian's Python 3.13.5 with a venv), mailauth in
node:24.22.0-bookworm-slim, opendkim-testkey in debian:trixie-20261005-slim and dig in
nicolaka/netshoot:v0.14, all on 20 October 2026.