Internet Protocols · Email Protocols
DKIM Canonicalization and What It Actually Signs
How a DKIM signature survives transport rewriting, why two canonicalization algorithms exist, and which header fields the signature deliberately excludes.
A signature that covers a message verbatim cannot survive an SMTP path. Relays rewrite Received headers, add trace fields, may re-fold long lines, and may alter whitespace at the end of a line. A domain signature has to cover something narrower than the transmitted octets but wide enough that altering the message’s meaning invalidates it. DKIM’s answer is a canonicalization step applied to headers and body separately before hashing, and the choice of algorithm is the whole design.
The signing unit is a hash of canonical forms
DKIM computes a signature over two hashes: one over selected header fields in a specified order, and one over the body. The signature is carried in a DKIM-Signature header field, and verification recomputes both hashes from the message as received, using the algorithms the field names.
The field carries the parameters needed for verification:
DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=example.com;
s=selector1; t=1704067200; h=from:to:subject:date:message-id;
bh=...; b=...
v is the version, always 1. a names the algorithm, which in practice is rsa-sha256 after RFC 8301 deprecated rsa-sha1. c names the canonicalization for headers and body, in that order. d is the signing domain, s the selector naming a key under that domain, and h the list of header fields signed. bh is the body hash, base64 encoded; b is the signature itself.
The h= list is the part that determines what the signature means. It is a list of field names, and verification takes the last instance of each named field from the message, in the order the names appear in h=, not the order they appear in the message. That ordering rule is what prevents an attacker from reordering fields to change the hash, and it is why a verifier must select fields by name rather than iterate the message.
A field named more than once in h= is signed multiple times, using successive instances from the bottom of the header block upward. This allows a signer to cover repeated fields, but it also means a message with three Received fields and an h= list that names received twice signs the last two. The instance counting is exact and is a common source of verification failures when a relay adds a field the signer expected to be absent.
Relaxed and simple canonicalization
Two algorithms exist for each of the two parts, and they differ in how much whitespace and line-ending variation they forgive.
Simple canonicalization is nearly verbatim. For the body, it removes trailing empty lines and ensures the body ends with a single CRLF. Nothing else changes: whitespace inside lines, the number of spaces after a colon in a header, and the exact folding of a header are all preserved and all covered by the hash.
Relaxed canonicalization normalizes more aggressively. For the body, it reduces every sequence of whitespace within a line to a single space, removes trailing whitespace at the end of each line, removes trailing empty lines, and ensures a final CRLF. For headers, it converts the field name to lowercase, removes whitespace around the colon, collapses internal whitespace sequences to a single space, removes trailing whitespace, and unfolds the field.
The body treatment is where the practical difference lies. A message that travels through a relay which re-encodes quoted-printable content, or a gateway that rewraps text at a different column, changes its body octets without changing its text. Simple canonicalization breaks; relaxed survives, provided the rewrapping only changed whitespace and line breaks rather than the words.
The cost of relaxed is that it permits an attacker to alter whitespace in ways the reader might notice. Adding spaces inside a URL, for instance, can change what a reader sees as a link while leaving the relaxed body hash intact, because the collapse happens at verification time on both sides. Relaxed/relaxed is therefore the common choice for messages that must survive real transport, and simple/simple is used where the path is controlled end to end.
Mixing the two is legal and common: c=relaxed/simple signs headers with relaxed normalization and the body verbatim. The choice is independent, and the header is usually the part that needs forgiveness because relays add fields and re-fold.
The header selection problem
A signature covers only the fields named in h=. Everything else can be added or altered after signing, and the signature still verifies. That is by design, since a relay must be able to add Received and trace fields, but it creates a class of attacks that DKIM addresses with explicit rules rather than with coverage.
From must be signed. RFC 6376 requires it, because the signature’s meaning is “this domain takes responsibility for this message”, and a message whose From can be changed after signing would let an attacker redirect the displayed author. A signature that omits From is invalid.
Fields that a signer should not cover include Received, Return-Path, Authentication-Results, and any field beginning with DKIM-Signature itself. The first three are added or rewritten by intermediaries, so signing them would guarantee failure. The last is excluded because a message can carry multiple signatures, and each one would otherwise have to cover the others.
The subtle case is a field that is signed but whose value a downstream hop may legitimately rewrite. Subject is signed, and a mailing list that adds a [list] tag to the subject breaks the signature. Message-ID is signed, and a gateway that regenerates it breaks the signature. Date is signed, and a system that normalizes timezone offsets breaks it. None of these rewrites is malicious, and all of them produce a fail result that is indistinguishable from forgery unless the verifier records which field failed.
Verification can report the failing field only if it recomputes incrementally, which most implementations do not. The practical diagnostic is to compare the h= list against the message and look for a field whose value differs from what the signer saw, which requires access to the original, information the receiver does not have. This is the reason list software and forwarders are the main source of DKIM failures, and it is why the mechanism that handles forwarding, described in ARC and Authentication Across Forwarding Hops, exists.
Length limits, body hashes, and the empty body
The l= parameter on a signature declares the body length covered. It exists to let a signer cover a prefix of the body, so that a mailing list appending a footer does not invalidate the signature.
The parameter is a known weakness. A verifier that honors l= hashes only the declared number of octets, so anything appended after that point is unsigned and can be altered freely. RFC 6376 discusses the risk in its security considerations and requires that a verifier honoring l= treat the signature as covering only the stated length. Deployed practice is mixed: some verifiers ignore l= entirely and hash the whole body, which breaks legitimate footer-appending lists; others honor it and accept the risk.
The empty body has its own rule. A body consisting only of CRLF canonicalizes, under both algorithms, to a single CRLF, and the body hash is the hash of that. A message with no body at all, only headers, is treated as having a body of a single CRLF rather than as having no body. Signers that compute the hash over zero octets produce a signature that never verifies.
The body hash is stored separately in bh= so that a verifier can check it before doing the more expensive signature verification. That ordering is a performance decision: an RSA verification is far more costly than a SHA-256 over the body, and rejecting on the body hash first avoids the asymmetric operation for messages that fail early. It also produces a distinct error class, body hash mismatch versus signature mismatch, which is useful for diagnosis.
Key publication and the trust boundary
The public key is published in DNS at <selector>._domainkey.<domain>, as a TXT record containing v=DKIM1; k=rsa; p=<base64 key>. The selector indirection lets a domain rotate keys without changing the signing domain, and lets different services use different keys under one domain.
The DNS lookup is the trust anchor, and it is a weak one. An attacker who controls the domain’s DNS can publish a key and sign anything. An attacker who can spoof DNS responses can substitute a key, which is why the lookup should be DNSSEC-validated where available. The key record also carries flags, including one that marks the key as testing, meaning a signature made with that key should not cause a message to be rejected, only observed.
import hashlib, base64, re
def canon_body_relaxed(body: bytes) -> bytes:
# Split on CRLF, strip trailing whitespace per line, collapse runs of spaces/tabs.
lines = body.split(b"\r\n")
out = []
for ln in lines:
ln = re.sub(rb"[ \t]+", b" ", ln).rstrip(b" ")
out.append(ln)
# Drop trailing empty lines, then terminate with exactly one CRLF.
while out and out[-1] == b"":
out.pop()
return b"\r\n".join(out) + b"\r\n"
def body_hash(body: bytes, alg="sha256") -> str:
h = hashlib.new(alg)
h.update(canon_body_relaxed(body))
return base64.b64encode(h.digest()).decode("ascii")
The code covers only the body and only the relaxed algorithm. Header canonicalization has to reproduce the field selection order from h=, which requires the full header block and the signature parameters, and getting the selection wrong produces a hash that differs from the signer’s for reasons that are not visible in any single field.
What the signature does not claim
A passing DKIM signature means the message was signed by a key published for the signing domain, and that the signed parts have not changed since. It does not mean the domain authorized the sending IP, which is SPF’s claim and is described in SPF Evaluation and Its Ten-Lookup Budget, and it does not mean the signing domain is the domain the user sees in From, unless the verifier checks that alignment explicitly.
That last gap is the reason a valid signature from an unrelated domain is worth little on its own. A spam sender can sign with its own domain and pass DKIM. The check that ties the signature to the displayed author is the alignment rule in DMARC, covered in DMARC Alignment Between Header and Envelope Domains.