Skip to main content
Defensive Security · lesson 8 of 8
Defensive Security advanced Lesson 8 of 8

Email Header and DKIM Triage: Signatures, Alignment and the Received Chain

Sign an email with DKIM locally, break it with a small edit, then triage a lookalike whose DKIM passes but isn't aligned with From. No real phishing.

By SkillByExample · Published

defensive-security email-security dkim phishing dmarc python

Reported phishing is a large share of a SOC’s daily queue, and the first look is always the same: the headers. In this lesson you’ll create a signing key, DKIM-sign an email the way a mail server does, and watch the signature fail when one word of the body changes. Then you’ll triage a lookalike message whose DKIM check passes and is still a fake, and see which header fields give it away. Everything is generated locally with reserved example domains; there are no real phishing samples. Tested with Docker Engine 29.8.2, Python 3.12.15 (python:3.12-slim) and dkimpy 1.1.8.

DKIM in one paragraph

The sending domain’s mail server signs selected headers and a hash of the body with a private key, and adds the result as a DKIM-Signature header. The public key is published in DNS as a TXT record at <selector>._domainkey.<domain>. A receiving server fetches that record and checks the signature. A pass proves two things: the signed parts haven’t changed since signing, and the domain in the signature’s d= tag signed it. It doesn’t prove the domain in the visible From line had anything to do with it.

The lab

# Dockerfile
FROM python:3.12-slim
RUN pip install --no-cache-dir --root-user-action=ignore dkimpy==1.1.8

Build it as dkim-lab. Every command below runs as docker run --rm -v "$PWD":/work -w /work dkim-lab <command>.

dkimpy ships dknewkey, which creates a 2048-bit RSA key and the DNS record that would publish it. Create one key for example.com, the real sender, and one for example.net, the lookalike.

$ dknewkey lab
generating lab.key
extracting lab.key
writing lab.dns
$ cut -c1-60 lab.dns
v=DKIM1; k=rsa; h=sha256; p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A
$ dknewkey other
generating other.key
extracting other.key
writing other.dns

lab.dns is the TXT record’s value: version, key type, hash, and p=, the public key in base64. In production it would be published as lab._domainkey.example.com. Here a small function plays the part of DNS.

Signing and verifying

The script writes a short invoice email from example.com and signs it, as the sending server would. include_headers lists the headers the signature covers.

# sign_email.py: write an email from example.com and DKIM-sign it, as its mail server would
from email.message import EmailMessage
from email.utils import formatdate, make_msgid
import dkim

msg = EmailMessage()
msg["From"] = "Billing <billing@example.com>"
msg["To"] = "alice@example.org"
msg["Subject"] = "Invoice 4471 is due on Friday"
msg["Date"] = formatdate(usegmt=True)
msg["Message-ID"] = make_msgid(idstring="4471", domain="mail.example.com")
msg.set_content("Hello Alice,\n\nInvoice 4471 for $120.00 is due on Friday.\n\nBilling\n")
raw = msg.as_bytes()

signature = dkim.sign(raw, selector=b"lab", domain=b"example.com",
                      privkey=open("lab.key", "rb").read(),
                      include_headers=[b"From", b"To", b"Subject", b"Date", b"Message-ID"])
open("signed.eml", "wb").write(signature + raw)
print(signature.decode()[:200])
$ python sign_email.py
DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/simple; d=example.com;
 i=@example.com; q=dns/txt; s=lab; t=1791966959; h=from : to : subject
 : date : message-id; bh=4D91ZI9RPjW/n7keH8TqVUnqchCKR6hSSC

The tags tell a verifier everything it needs: d=example.com and s=lab say where to find the key, h= lists the signed headers, and bh= is the hash of the body. c=relaxed/simple sets how strictly whitespace is treated in the headers and the body. The signature itself, b=, follows; the print cuts it off.

The verifier looks up the key with a local function in place of DNS, and reports why a check fails.

# verify.py: check each email's DKIM signature against our local "DNS" records
import sys
import dkim

RECORDS = {
    b"lab._domainkey.example.com.": open("lab.dns", "rb").read().strip(),
    b"lab._domainkey.example.net.": open("other.dns", "rb").read().strip(),
}

def local_dns(name, timeout=5):  # stands in for the DNS TXT lookup
    return RECORDS.get(name)

for path in sys.argv[1:]:
    try:
        ok = dkim.DKIM(open(path, "rb").read()).verify(dnsfunc=local_dns)
        why = "" if ok else " (signature does not match the signed headers)"
    except dkim.DKIMException as e:
        ok, why = False, f" ({str(e).split(' (got')[0]})"
    print(f"{path}: dkim={'pass' if ok else 'fail'}{why}")
$ python verify.py signed.eml
signed.eml: dkim=pass
$ sed 's/due on Friday\./due today./' signed.eml > edited.eml
$ python verify.py edited.eml
edited.eml: dkim=fail (body hash mismatch)
$ sed 's/^Subject: .*/Subject: URGENT: Invoice 4471 overdue/' signed.eml > subject.eml
$ python verify.py subject.eml
subject.eml: dkim=fail (signature does not match the signed headers)

Changing two words of the body changes its hash, so bh= no longer matches. Changing the subject breaks the signature over the headers. dkimpy raises an exception for the first case and returns False for the second, which is why verify.py handles both. Either way, a message altered after signing fails.

What DKIM doesn’t cover

The signature only covers the headers listed in h=. Add one that isn’t listed:

$ { echo 'Reply-To: payments@example.net'; cat signed.eml; } > replyto.eml
$ python verify.py replyto.eml
replyto.eml: dkim=pass

Still a pass, although replies now go to a different domain. Anyone who handles the message after signing can add an unsigned header like this. Mail software is expected to add headers in transit, such as Received, so DKIM leaves unsigned headers alone. The practice tasks show how a sender protects Reply-To.

Triage: a lookalike that passes

Now the case analysts actually face. This message claims to be from billing@example.com but was sent and signed by example.net, a domain its sender controls. The script also writes the Return-Path and Received headers that receiving servers add, so the file looks the way it would in an inbox.

# phish.py: a lookalike email, signed by the sender's own domain, as it lands in the inbox
from email.message import EmailMessage
from email.utils import formatdate, make_msgid
import dkim

msg = EmailMessage()
msg["From"] = "Billing <billing@example.com>"
msg["Reply-To"] = "payments@example.net"
msg["To"] = "alice@example.org"
msg["Subject"] = "Invoice 4471: our bank details have changed"
msg["Date"] = formatdate(usegmt=True)
msg["Message-ID"] = make_msgid(idstring="4471", domain="mail.example.net")
msg.set_content("Hello Alice,\n\nPlease pay invoice 4471 to our new account today.\n\nBilling\n")
raw = msg.as_bytes()
raw = dkim.sign(raw, b"lab", b"example.net", open("other.key", "rb").read()) + raw

now = formatdate(usegmt=True)  # the receiving servers add these on the way in
hops = (f"Return-Path: <bounce@example.net>\n"
        f"Received: from mx.example.org (mx.example.org [192.0.2.25])\n by mailbox.example.org with LMTP; {now}\n"
        f"Received: from mail.example.net (mail.example.net [203.0.113.45])\n by mx.example.org with ESMTPS; {now}\n")
open("phish.eml", "wb").write(hops.encode() + raw)
print("wrote phish.eml")
$ python phish.py
wrote phish.eml
$ python verify.py phish.eml
phish.eml: dkim=pass
$ head -12 phish.eml
Return-Path: <bounce@example.net>
Received: from mx.example.org (mx.example.org [192.0.2.25])
 by mailbox.example.org with LMTP; Wed, 14 Oct 2026 08:36:30 GMT
Received: from mail.example.net (mail.example.net [203.0.113.45])
 by mx.example.org with ESMTPS; Wed, 14 Oct 2026 08:36:30 GMT
DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/simple; d=example.net;
 i=@example.net; q=dns/txt; s=lab; t=1791966990; h=from : reply-to : to
 : subject : date : message-id : content-type :
 content-transfer-encoding : mime-version : from;
 bh=Fx4w6Op6Q/FlwzesSioGEJoNGqi9uKyCBhdWUSdbycc=;
 b=DPDQk3k615Eah/68QKs2ORq52k1eaeGbeGxnvAPJGULmk+GhcRhsoYsQ/UH9K10QTaeNg
 p5ZKWWlvO+V4u8wltiQcTrE7PxVXwpJBjudWeqWiGv/QDNPPfsM3dPPqSje+UKCizB+TN8l

DKIM passes, because the message really was signed by example.net and hasn’t been changed. That’s all DKIM checks. Without include_headers, dkimpy signed its default list, and from appears twice in h=: listing a header once more than it occurs stops anyone adding a second From line later. The addresses 192.0.2.25 and 203.0.113.45 are from ranges reserved for documentation.

Headers are added at the top as a message travels, so read the Received chain from the bottom up. The line that matters most is the lowest one your own server added: it records which address handed the message to you from outside. Anything below that came from the sender’s side and can be forged. Here your servers added both lines, so it’s the bottom one.

# headers.py: read the headers an analyst checks first
import re, sys
from email import policy
from email.parser import BytesParser
from email.utils import parseaddr

msg = BytesParser(policy=policy.default).parse(open(sys.argv[1], "rb"))
domain = lambda header: parseaddr(str(msg.get(header, "")))[1].rpartition("@")[2] or "-"

print("Received chain, first hop first:")
for n, hop in enumerate(reversed(msg.get_all("Received", [])), 1):
    print(f"  {n}. {' '.join(str(hop).split(';')[0].split())}")

dkim_d = re.search(r"\bd=([^;\s]+)", str(msg.get("DKIM-Signature", "")))
signer = dkim_d[1] if dkim_d else "-"
print(f"From domain:        {domain('From')}")
print(f"Return-Path domain: {domain('Return-Path')}")
print(f"Reply-To domain:    {domain('Reply-To')}")
print(f"DKIM signing (d=):  {signer}")
print("DKIM aligned with From:", signer == domain("From"))
$ python headers.py phish.eml
Received chain, first hop first:
  1. from mail.example.net (mail.example.net [203.0.113.45]) by mx.example.org with ESMTPS
  2. from mx.example.org (mx.example.org [192.0.2.25]) by mailbox.example.org with LMTP
From domain:        example.com
Return-Path domain: example.net
Reply-To domain:    example.net
DKIM signing (d=):  example.net
DKIM aligned with From: False
$ python headers.py signed.eml
Received chain, first hop first:
From domain:        example.com
Return-Path domain: -
Reply-To domain:    -
DKIM signing (d=):  example.com
DKIM aligned with From: True

Every technical field points to example.net: the server that handed the message in, the bounce address, the reply address and the DKIM signer. Only the From line, the one the recipient sees, says example.com. That mismatch is what DMARC formalises as alignment: a DMARC pass needs SPF or DKIM to pass for a domain that matches From. Here DKIM passes for the wrong domain, so DMARC fails, and example.com could publish a DMARC policy telling receivers to reject such mail. SPF, the other half, checks the Return-Path domain against the sending server’s IP address, which needs real DNS and a real connection, so this lab doesn’t run it.

The genuine message has no Received or Return-Path lines because it never travelled; sign_email.py wrote it straight to a file. Its signer matches its From domain.

That completes the topic. The roadmap shows where defensive security fits among the other security topics.

Practice

  1. Protect Reply-To on the genuine message: re-sign signed.eml with Reply-To added to include_headers, although the message has no Reply-To. Then add one, as before, and verify.
Solution
# oversign.py: re-sign signed.eml, this time also covering Reply-To, which it doesn't have
import re
import dkim

data = open("signed.eml", "rb").read()
raw = re.sub(rb"^DKIM-Signature:.*?\n(?=\S)", b"", data, count=1, flags=re.S)
signature = dkim.sign(raw, b"lab", b"example.com", open("lab.key", "rb").read(),
                      include_headers=[b"From", b"To", b"Subject", b"Date", b"Message-ID",
                                       b"Reply-To"])
open("oversigned.eml", "wb").write(signature + raw)
print(signature.decode().split("bh=")[0])
$ python oversign.py
DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/simple; d=example.com;
 i=@example.com; q=dns/txt; s=lab; t=1791967011; h=from : to : subject
 : date : message-id : reply-to;

$ python verify.py oversigned.eml
oversigned.eml: dkim=pass
$ { echo 'Reply-To: payments@example.net'; cat oversigned.eml; } > replyto2.eml
$ python verify.py replyto2.eml
replyto2.eml: dkim=fail (signature does not match the signed headers)

Signing a header that isn’t there signs its absence. Adding one later changes the signed data, so the check fails. This is called oversigning.

Don’t count on a library’s defaults to do it for you. dkimpy 1.1.8 oversigns only From (dkim.DKIM.FROZEN is (b'from',)); every other header in its default list is signed only if the message has it. reply-to appears in phish.eml’s h= because that message has a Reply-To. Sign the genuine message with the defaults and repeat the test:

# default_sign.py: re-sign signed.eml with dkimpy's default header list
import re
import dkim

data = open("signed.eml", "rb").read()
raw = re.sub(rb"^DKIM-Signature:.*?\n(?=\S)", b"", data, count=1, flags=re.S)
signature = dkim.sign(raw, b"lab", b"example.com", open("lab.key", "rb").read())
open("default.eml", "wb").write(signature + raw)
print(signature.decode().split("bh=")[0])
$ python default_sign.py
DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/simple; d=example.com;
 i=@example.com; q=dns/txt; s=lab; t=1791968796; h=from : to : subject
 : date : message-id : content-type : content-transfer-encoding :
 mime-version : from;
$ { echo 'Reply-To: payments@example.net'; cat default.eml; } > replyto3.eml
$ python verify.py default.eml replyto3.eml
default.eml: dkim=pass
replyto3.eml: dkim=pass

from is listed twice, and reply-to not at all, so the added Reply-To passes. To protect a header the message doesn’t have, name it in include_headers yourself, as oversign.py does. These two runs used a freshly generated key pair, which is why the t= timestamps differ from the earlier listing.

  1. DMARC’s default alignment is relaxed: mail.example.com counts as aligned with example.com, because they share an organisational domain. Write an aligned() function with a relaxed and a strict mode and test three pairs.
Solution
# aligned.py: DMARC-style relaxed alignment between the DKIM d= domain and the From domain
def org(domain):  # naive: the last two labels; real code uses the Public Suffix List
    return ".".join(domain.lower().split(".")[-2:])

def aligned(signer, from_domain, strict=False):
    return signer == from_domain if strict else org(signer) == org(from_domain)

for signer, sender in [("example.com", "example.com"), ("mail.example.com", "example.com"),
                       ("example.net", "example.com")]:
    print(f"{signer:<17} {sender:<12} relaxed={aligned(signer, sender)}"
          f" strict={aligned(signer, sender, strict=True)}")
$ python aligned.py
example.com       example.com  relaxed=True strict=True
mail.example.com  example.com  relaxed=True strict=False
example.net       example.com  relaxed=False strict=False

The lookalike fails both modes. The “last two labels” shortcut is wrong for domains like example.co.uk, where the organisational domain has three labels; real DMARC code uses the Public Suffix List to find it.

  1. In phish.eml, which IP address would you look up in your threat-intel and mail logs, and why not the other one?
Solution

203.0.113.45, from the bottom Received line. It’s where your own server, mx.example.org, received the message from outside, so your server wrote that line and you can trust it. 192.0.2.25 is your own mail exchanger handing the message to the mailbox server. Any Received lines below the first one your servers added were written by the sender’s side and can be invented, so trust stops at your own border.

Frequently Asked Questions

What does a DKIM pass actually prove?
It proves that the domain named in the signature's d= tag signed the message, and that the signed headers and the body have not changed since. It does not prove that this domain matches the From address the recipient sees. A phishing email signed by the attacker's own domain passes DKIM; DMARC alignment is the check that catches it.
Why does DKIM fail after an email is forwarded or edited?
The signature covers a hash of the body and a list of headers. Any change to them, such as a mailing list adding a footer, a security gateway rewriting links or a one-word edit, changes the hash and breaks the signature. Headers not listed in the signature's h= tag can change without affecting it.
What is DMARC alignment?
Alignment means the domain that passed SPF or DKIM matches the domain in the visible From header. Relaxed alignment, the default, accepts the same organisational domain, such as mail.example.com for example.com; strict alignment requires an exact match. DMARC passes only if at least one of SPF or DKIM passes and is aligned.
How do you read the Received headers of an email?
Each mail server that handles a message adds a Received header at the top, so read them from the bottom up. The lowest Received line written by your own organisation's server shows the IP address that delivered the message from outside, and that is the one to investigate. Lines below it come from the sender's side and can be forged.
What is DKIM oversigning?
Oversigning means listing a header in the DKIM signature more times than it appears, including headers the message doesn't have. Because the signature then covers the header's absence, anyone who adds a second From or a new Reply-To after signing breaks the signature. Signing libraries such as dkimpy oversign From by default.