DKIM is usually treated like a black box: the email goes out, the receiver says dkim=pass or dkim=fail, and nobody really knows what happened in between. Steve Atkins, from Word to the Wise, published a short exercise on October 27 for opening that box with a terminal and openssl: calculating by hand the body hash that goes in the signature's bh= tag. It's worth doing because that hash is exactly the step that breaks most often when a server or a library signs incorrectly, and checking it takes a minute. Here's the exercise, a Bash gotcha that isn't in the original, and a test I actually ran.
What happens before signing
RFC 6376, which defines DKIM, says in section 3.7 that signing and verifying start the same way: with two hashes. The first is of the message body, already canonicalized; that result, in base64, is what shows up in bh=. The second covers the headers listed in the h= tag, plus the DKIM-Signature header itself with an empty b= tag. Only that second hash is signed with the private key, and the result goes in b=.
The RSA part can't be done by hand, as Atkins himself admits. But the body hash can, and it already tells you a lot.

Canonicalization: simple vs. relaxed
Before hashing, the body gets "canonicalized," that is, normalized so that trivial changes along the way don't break the signature. The c= tag declares it with two words separated by a slash: the first is for the headers and the second is for the body. In c=relaxed/simple, the body uses simple. If the tag is missing, the RFC says the default value is simple/simple.
- Simple (section 3.4.3): removes trailing empty lines and, if there's no CRLF at the end, adds one. Nothing else.
- Relaxed (section 3.4.4): also removes trailing spaces and tabs at the end of each line and collapses any run of spaces within a line into a single one. It also removes trailing empty lines.
CRLF is the email line ending: bytes 13 and 10, written \r\n in the terminal.
The interesting case is the empty body. With simple it becomes a two-byte CRLF; with relaxed it ends up as zero bytes. And the RFC includes both expected SHA-256 hashes written right in the text, so you have something to compare against.
The exercise, in your terminal
For the empty body with simple canonicalization:
printf '\r\n' | openssl sha256 -binary | base64
For relaxed:
printf '' | openssl sha256 -binary | base64
The results I got are exactly the ones in the RFC:
| Body canonicalization | Bytes being hashed | bh= (SHA-256) |
|---|---|---|
| simple | CRLF (2 bytes) | frcCV1k9oG9oKj3dpUqdJg1PxRT2RSN/XKdLCPjaYaY= |
| relaxed | nothing (0 bytes) | 47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU= |
Atkins' proposal for checking it with real email: send yourself a message with no body, open the original and look for the DKIM-Signature header. If it says c=…/simple, the bh= should be the first one; if it says c=…/relaxed, the second. If it doesn't match, whoever is signing is canonicalizing wrong. Atkins suggests it as a quick test for newly written or newly configured signing code.
The Bash gotcha
The original article uses echo -n "\r\n". Careful: in Bash, echo doesn't interpret \r\n by default, so it sends four literal characters (a backslash, an r, another backslash, an n). I checked: in Bash that command gives zjGKtEkm8ss54KepwId5Q7EXM6uOYRy7Fw/u8aPVxUk=, which is not the correct hash. In zsh, the default shell on macOS, echo does interpret the sequences, which is why the example works there. printf behaves the same in both, so it's the safe option. It's the kind of mistake that makes you think your signature is broken when what's broken is your test.
One more step: a body that isn't empty
To see the difference between the two canonicalizations, I put together a body with "Hola", two spaces, "mundo", a space, a tab, and two empty lines at the end. I signed it with dkimpy 1.1.8, a Python library, using a throwaway 2048-bit RSA key and a simulated DNS lookup in the same script. I didn't use a real domain or send it to any mailbox.

Here's what came out:
c=relaxed/simple: thebh=wasjuK/TBgWpMJK3vDkztZJleJYe6pij7a9l+Llw26v110=, the same oneopensslgives over the original line without the empty lines. When I added spaces at the end of the line, verification failed.c=relaxed/relaxed: thebh=wasxmFamFbJ3x0s4j/SB7mPftiyd8AMni6zF0oiedfSu6c=, the hash ofHola mundoplus a CRLF. With the same added spaces, verification passed.- In both cases, changing "Hola" to "HOLA" broke the signature. Neither canonicalization forgives content changes.
To reproduce it without libraries, the relaxed body is printf 'Hola mundo\r\n' and the simple one is printf 'Hola mundo \t\r\n', each piped through openssl sha256 -binary | base64.
How to use it for troubleshooting
My practical take, for anyone who runs email for their systems:
- Compare
bh=first. If the receiver says the body hash doesn't match, the problem is in the body: a relay that rewrites it, a footer added by a mailing list or an antivirus, or a library that canonicalizes wrong. Ifbh=matches but the signature doesn't, look at the headers or at the key published in DNS. - Prefer
relaxed/relaxedif your emails go through intermediaries that touch spaces or line endings. Simple is stricter and breaks more easily. - Don't use
l=. That tag signs only the first bytes of the body. RFC 6376, in section 8.2, warns that it lets an intermediary add content without breaking the signature. - Use
a=rsa-sha256. RFC 8301 banned signing and verifying withrsa-sha1, and requires RSA keys of at least 1024 bits, recommending 2048.
This doesn't replace a full validator, but it turns an opaque "dkim=fail" into a concrete question: did the body fail or did the headers fail?
Sources
- RFC 6376: DomainKeys Identified Mail (DKIM) Signatures
- RFC 6376, section 3.4.3: simple body canonicalization
- RFC 6376, section 3.4.4: relaxed body canonicalization
- RFC 6376, section 3.7: computing the message hashes
- RFC 8301: updates to DKIM algorithms and key sizes
- Fun with DKIM, Word to the Wise
- dkimpy on PyPI