What the receiver actually checks

A DKIM signature is a header your outgoing mail server adds:

DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=example.com; s=s1;
        h=from:to:subject:date:message-id:mime-version:content-type;
        bh=2jUSOH9NhtVGCQWNr9BrIAPreKQjO6Sn7XIkfJVOzv8=;
        b=Kx3mP9qa...

The receiver does two separate checks, in this order:

  1. Body hash. It canonicalizes the body it received (the c= tag says how), hashes it, and compares the result with bh=. No key is needed for this step.
  2. Signature. It takes the headers named in h=, plus the DKIM-Signature header itself with b= emptied, hashes them, and checks that hash against b= using the public key it fetches from DNS at s1._domainkey.example.com (selector, then _domainkey, then the d= domain).

That ordering is the most useful fact in this whole topic. A body hash failure means the body changed after signing; the key is irrelevant. A signature failure with a good body hash means either a signed header changed or the key in DNS doesn’t match the private key that signed. Different problems, different people to talk to.

DKIM doesn’t judge whether mail is wanted or safe. It only says: these bytes are what the owner of d= signed.

Step 1: read the result line, not the dashboard

Open the received message’s full headers. In Gmail, “Show original” (it also offers “Download original”). In Outlook, the message source or “View message details”. Find the Authentication-Results header written by the receiving system.

Gmail reports a body hash mismatch like this:

Authentication-Results: mx.google.com;
       dkim=neutral (body hash did not verify) header.i=@example.com header.s=s1 header.b=Kx3mP9qa;
       spf=pass (google.com: domain of bounce@example.com designates 203.0.113.10 as permitted sender) smtp.mailfrom=bounce@example.com;
       dmarc=pass (p=QUARANTINE sp=QUARANTINE dis=NONE) header.from=example.com

Note that Gmail calls it neutral, not fail. Note also that DMARC still passed, because SPF passed and aligned. That message was delivered. Forward the same message once and SPF breaks too, and then DMARC fails. A broken DKIM signature is often invisible until the day mail is forwarded.

Microsoft 365 and Outlook.com put the reason in parentheses, along these lines:

Authentication-Results: spf=pass (sender IP is 203.0.113.10) smtp.mailfrom=example.com;
 dkim=fail (body hash did not verify) header.d=example.com;
 dmarc=pass action=none header.from=example.com;compauth=pass reason=100

Other wordings you’ll meet are signature did not verify, no key for signature, message not signed, and from OpenDKIM-based systems reason="signature verification failed".

The possible DKIM results, and what each one points to:

ResultMeaningLook at
passA signature verifiedAlignment, if DMARC still fails
failA signature was checked and didn’t verifyBody changes, header changes, key mismatch
neutralCouldn’t be processed, or (Gmail) body hash mismatchSame as fail
noneNo DKIM signature on the messageSigning isn’t turned on for this sender
temperrorTemporary problem, usually a DNS timeout fetching the keyYour DNS provider’s reliability
permerrorPermanent problem: key missing, key record unparsable, malformed signatureThe _domainkey record

Three traps when reading headers:

  • Trust only your own receiver’s line. Anyone can put an Authentication-Results header into a message they send. The one that counts is the topmost one carrying your receiving system’s name (mx.google.com, your gateway’s hostname).
  • There may be several signatures. Mail sent through an email service provider often carries the provider’s signature and yours. One dkim=fail next to a dkim=pass is not a problem if the pass is the one with your domain in d=.
  • dkim=none is not a broken signature. It means the sending system never signed. Chasing body changes won’t help; turn on signing in that system.

The email header analyzer pulls out the SPF, DKIM and DMARC verdicts from Authentication-Results and lays out the Received hops with the delay between each, which helps you see whether a list server or gateway sat in the path. It reports the receiver’s verdict; it doesn’t re-verify the signature itself.

Step 2: verify the message yourself

Download the original message as a file (Gmail: “Download original”). Copying text out of a web view changes whitespace and line endings and will produce a false failure.

With the Python dkimpy package:

pip install dkimpy
dkimverify < message.eml

It tells you whether the signature verifies on the bytes you have. If the message carries several signatures, make sure you know which one was checked.

Then check the key the receiver would fetch. Take the selector from s= and the domain from d=:

dig +short TXT s1._domainkey.example.com
"v=DKIM1; k=rsa; p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAvb2y..." "...QIDAQAB"

Two quoted strings in one answer is normal: a 2048-bit key is longer than the 255-character limit for a single TXT string, so it’s split, and receivers join the strings back together. Two separate TXT records at that name is not normal (see below).

To see the key size, decode the p= value:

dig +short TXT s1._domainkey.example.com | tr -d '" \n' | sed 's/.*p=//; s/;.*//' > key.b64
{ echo '-----BEGIN PUBLIC KEY-----'; fold -w 64 key.b64; echo; echo '-----END PUBLIC KEY-----'; } \
  | openssl pkey -pubin -noout -text | head -1
Public-Key: (2048 bit)

If OpenSSL can’t parse it, the record is damaged. A quick visual check: a 2048-bit RSA key in this format starts with MIIBIjANBgkq, a 1024-bit one with MIGfMA0GCSq.

If you run OpenDKIM, compare the private key on disk with what’s in DNS:

opendkim-testkey -d example.com -s s1 -k /etc/opendkim/keys/example.com/s1.private -vvv

key OK means they match. key not secure on the way there only means the zone isn’t signed with DNSSEC; it isn’t an error.

Check from more than one resolver (dig @1.1.1.1 …, dig @8.8.8.8 …) after any change. The DNS lookup shows the exact TXT record as the outside world sees it.

“Body hash did not verify”: something rewrote the body

This is the most common DKIM failure, and the key is never the cause. Look for whatever touched the body between signing and delivery.

A footer or disclaimer added after signing. The classic setup: your mail server signs, then an outbound security gateway or a transport rule appends a legal disclaimer, a “sent from” line or a tracking pixel. Each layer works on its own; together they guarantee failure. The fix is ordering: whatever signs must be the last step before the message leaves your network. If the gateway adds the footer, the gateway (or your provider, after the gateway) has to do the signing.

A mailing list or forwarder. Lists append unsubscribe footers and sometimes rewrite the body. Plain forwarding usually leaves the body alone, but some forwarding services re-encode messages. You can’t fix this from the sending side. Well-behaved lists either leave the message untouched, or rewrite the From: to the list’s own address and sign with the list’s domain; many also add ARC headers so the final receiver can see that DKIM passed before the list touched it. Whether a receiver trusts ARC is up to the receiver.

Transfer encoding changed in transit. If a relay along the way doesn’t accept 8-bit content, it may convert the body to quoted-printable or base64. The text reads the same but the bytes differ, so the hash differs. The same goes for lines longer than SMTP’s 998-character limit being re-wrapped. Sign messages that are already 7-bit safe (have your mailer encode non-ASCII bodies as quoted-printable or base64 before signing) and keep generated HTML lines short. Bulk HTML mail with one enormous line is a regular offender.

Brittle canonicalization. c=simple for the body means trailing whitespace changes matter. relaxed tolerates whitespace changes inside lines and at line ends. Use c=relaxed/relaxed unless you have a reason not to; most platforms default to it.

Don’t reach for the l= tag to “fix” footer problems. It signs only the first N bytes of the body, so a footer passes. It also means anyone can append content to your signed message and it still passes. RFC 6376 warns about exactly this, and some receivers treat l= signatures with suspicion.

“Signature did not verify”: a header changed, or the key doesn’t match

The body arrived intact, but the second check failed.

A signed header was changed. A list adds [listname] to the Subject, a gateway tags it [EXTERNAL], or something rewrites the From: display name. Any header in h= is covered. A header named in h= that didn’t exist at signing time is covered too: signing an absent Reply-To means anyone adding one later breaks the signature. That’s deliberate protection, but it also means a list that adds such a header breaks you.

The key in DNS isn’t the key that signed. This happens around key rotation:

  • The private key was regenerated on the server but the old public key is still in DNS.
  • DNS was updated, but a second sending server (a backup MTA, a web server’s local mailer, an application container) still signs with the old private key under the same selector.
  • Someone reused a selector name for a new key while mail signed with the old one was still in transit or in retry queues.

Rotate with two selectors: publish the new key under a new selector, wait for DNS to propagate, switch signing to it, and leave the old selector’s record in place for at least a few days so delayed mail still verifies.

The DNS record is damaged. The ones that come up again and again:

  • Doubled domain. Many DNS panels append the zone automatically. Entering s1._domainkey.example.com creates s1._domainkey.example.com.example.com. Enter s1._domainkey only.
  • Split into two records instead of two strings. The key ends up as two TXT records at the same name, each holding half a key. Receivers get two incomplete keys. It must be one record containing two strings.
  • Copy-paste damage. Line breaks, stray quotes or spaces pasted into the middle of p=, or the value truncated at 255 characters by a panel that doesn’t handle long records.
  • Missing or broken CNAME. Microsoft 365 and most email service providers have you add CNAMEs (selector1._domainkey, selector2._domainkey in Microsoft’s case) that point to keys they host. If the CNAME is missing or has a typo, there is no key.

No key at all, and other permanent errors

no key for signature, permerror or a key lookup returning NXDOMAIN all mean the receiver found nothing at selector._domainkey.domain. Check the exact selector from the failing message’s s=, not the one you think you configured. If a vendor signs for you, the selector is theirs.

An empty p= (v=DKIM1; p=) is how a key is revoked on purpose. If you see it, someone retired that selector while a system was still using it.

Signatures can carry an expiry (x=). A message that sits in a retry queue past that time arrives with an expired signature, which fails. If you set x=, give it days, not minutes.

temperror usually means the receiver timed out fetching your key. One-off occurrences happen; a pattern points at your DNS hosting.

Ed25519 signatures (RFC 8463) are valid DKIM, but receiver support is uneven. If you sign with Ed25519, sign with RSA as well.

DKIM passes, DMARC still fails

If the header shows dkim=pass and dmarc=fail, the signature is fine. It’s just for the wrong domain. DMARC only counts a DKIM pass when d= matches the From: domain (exactly under strict alignment, or the same organizational domain under the default relaxed alignment).

The usual reasons:

  • Google Workspace signs with a gappssmtp.com domain until you generate a key in the Admin console, publish it and click “Start authentication”.
  • Microsoft 365 signs with your onmicrosoft.com domain until you publish the two selector CNAMEs and enable DKIM for your own domain.
  • Email service providers sign with their own domain unless you set up domain authentication in their dashboard.

Fixing that, and deciding when to move DMARC from none to reject, is covered in DMARC p=none vs quarantine vs reject.

Checking a domain’s DKIM from outside

DKIM keys can’t be listed: you have to know the selector. The email check tries 18 common selectors (google, default, k1, selector1, selector2, s1, s2, the Proton Mail, Fastmail and Zoho ones, and Google’s date-style names such as 20230601). “Not detected” there means none of those names has a key, not that the domain doesn’t sign. Your provider may use another selector; read s= from a real message and look it up directly.

To fix a missing setup from scratch rather than diagnose a failure, start with the email deliverability guide. For a quick read of a domain’s MX, SPF and DMARC alongside DKIM, see how to check MX, SPF, DKIM and DMARC records.

Check your own domain now

Free, no sign-up. Runs the exact check this guide describes and shows what to fix.