Skip to content
ecdsa.com
← All articles

Why DKIM fails: body hash, bad signature, keys and DNS

DKIM breaks when a message changes after signing or when the verifier cannot get a usable key. Each failure mode below was reproduced with dkimpy and mailauth, with the exact verdict it produces, so you can match what you see in Authentication-Results.

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 header (dkimpy 1.1.8, folded as generated)
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:

TagMeaningTypical failure
d=, s=Signing domain and selector. The key is fetched from s._domainkey.dWrong selector, missing or revoked record
c=Header/body canonicalization, simple or relaxed. Default simple/simpleWhitespace changes break simple
h=Signed header fields, in orderAny change to one of them
bh=Base64 hash of the canonicalized bodyBody 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 hashedLets anyone append content (see below)
a=Algorithm: rsa-sha256 or ed25519-sha256rsa-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:

dkimpy 1.1.8, Python 3.13.5 (log lines from dkimpy, base64 values in full)
--- 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:

  1. Unchanged message. The body hash matches, so the signature verifies.
  2. Footer appended. A mailing list adds an unsubscribe footer. The computed body hash (got) differs from bh= (expected), so verification stops before it reaches the signature.
  3. 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.
  4. Whitespace. The test doubles a space in the Subject, collapses a double space in the body and adds a trailing space. With relaxed/relaxed the signature still verifies. With simple/simple the 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:

mailauth 8.0.1: Authentication-Results, dkim part
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:

SymptomUsual culprit
Body hash mismatch on every message from one senderA 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 listThe list software adds a footer or rewrites MIME parts
Body hash mismatch only for messages with non-ASCII text8-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 scriptBare LF line endings signed, CRLF on the wire, or the other way round
Bad signature, body hash OKSubject 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 messagePrivate 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:

dkimpy 1.1.8: a signature with l=
--- 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:

dkimpy 1.1.8: key problems
--- 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 (OpenDKIM 2.11.0~beta2, Debian 13), 20 October 2026
$ 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:

dig +short TXT google._domainkey.github.com (BIND 9.20.10, 20 October 2026)
"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

  1. Get the raw message as received, with all headers: "Show original" in Gmail, "View source" in Outlook, or the .eml file. Forwarding the message to yourself changes it.
  2. Read Authentication-Results from your receiving server, the topmost one. Note the DKIM verdict and its reason: body hash, bad signature, no key, permerror.
  3. Read the DKIM-Signature: d= and s= (which key), c= (how strict), h= (which headers), l= (should be absent).
  4. Check the key: dig +short TXT s._domainkey.d or opendkim-testkey -d d -s s -vvv. Is it there, not revoked, at least 1024 bits, and complete?
  5. Body hash mismatch: look for what was added or re-encoded: footers, banners, Content-Transfer-Encoding changes 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.
  6. 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.
  7. 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_milters or content_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 into h= 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 From and 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.