DEV Community

tony chen
tony chen

Posted on

Debugging Failing Email Signatures After a 2-Stage DKIM Rotation in Python

Treat a half-completed DKIM rotation as a state-reconciliation problem: keep the old selector published, identify which messages use each selector, and finish the missing DNS change before retiring anything. The deciding constraint is not the key age. It is whether every signing path and every authoritative DNS zone agree on the active selector.

Short answer: inspect a failing message's DKIM-Signature header, extract its d= domain and s= selector, and query that exact TXT name through the domain's authoritative DNS path. Compare it with a passing message. If the internal admin console can manage both platform-owned and customer-owned zones, record desired state separately from observed DNS state; a successful write request is not proof that the public key is visible.

This matters in customer support systems because outbound mail rarely follows one path. Ticket replies, password resets, escalations, and bulk notifications can be signed by different workers or configurations. A rotation that updates one signer or one zone can therefore look healthy in a dashboard while only part of the mail stream verifies.

Why Are Email Signatures Failing After a DKIM Rotation?

DKIM verification follows values carried by each message. The verifier uses the signing domain in d= and the selector in s= to locate the public key in DNS. During a rotation, old and new selectors can coexist. That overlap is useful: messages already in queues may still carry the old selector while newly configured signers use the new one.

Half-completed rotation usually means the system has split state. One sender signs with the new selector but its corresponding TXT record is absent from the authoritative zone. Another sender still uses the old selector, whose record remains available, so those messages pass. The inverse can happen if the old record is removed too early.

Start with the message, not the console.

One header can settle the first branch.

DMARC adds another dimension: a DKIM result contributes to DMARC only when the authenticated DKIM domain aligns with the domain visible in the message's From header. A DKIM signature can verify cryptographically and still fail to satisfy DMARC alignment. That distinction prevents a costly debugging detour: first determine whether DKIM verification failed, or whether DKIM passed but did not align.

Reconcile message evidence with authoritative DNS

My first-pass notebook for this problem is deliberately small. It groups exported message metadata by signing domain and selector, then highlights mixed outcomes. It does not attempt cryptographic verification; the mail receiver's authentication result and the original headers remain the evidence.

from collections import defaultdict
from dataclasses import dataclass


@dataclass(frozen=True)
class Sample:
    message_id: str
    stream: str
    header_from: str
    dkim_domain: str
    selector: str
    dkim_result: str


def summarize(samples: list[Sample]) -> dict[tuple[str, str], dict[str, object]]:
    groups: dict[tuple[str, str], dict[str, object]] = defaultdict(
        lambda: {"pass": 0, "fail": 0, "streams": set()}
    )
    for sample in samples:
        key = (sample.dkim_domain.lower(), sample.selector.lower())
        bucket = groups[key]
        if sample.dkim_result in {"pass", "fail"}:
            bucket[sample.dkim_result] += 1
        bucket["streams"].add(sample.stream)
    return dict(groups)


samples = [
    Sample("m-101", "ticket-reply", "support@example.test", "example.test", "support-a", "pass"),
    Sample("m-102", "password-reset", "support@example.test", "example.test", "support-b", "fail"),
    Sample("m-103", "escalation", "support@example.test", "example.test", "support-a", "pass"),
]

for (domain, selector), result in summarize(samples).items():
    streams = ", ".join(sorted(result["streams"]))
    print(domain, selector, result["pass"], result["fail"], streams)
Enter fullscreen mode Exit fullscreen mode

The focused comparison is support-a against support-b, not a global delivery-rate graph. For each failing selector, construct the lookup name from the observed s= and d= values, query DNS, and follow the delegation to the authoritative source. Then compare four pieces of evidence in one row: message timestamp, mail stream, authentication result, and observed TXT value. A message signed by support-b with no corresponding authoritative record points toward publication state; a message still signed by support-a after the intended cutover points toward rollout state. Those are different repairs. A recursive resolver's cached answer is operationally relevant, but it should not be mistaken for the zone's current contents, and a current authoritative answer does not prove what a receiver observed earlier. Preserve the timestamps.

Keep the sample dimensions intact. If failures cluster by stream, region, worker pool, or customer domain, that pattern can identify the signer that missed deployment. If the same selector passes and fails across receivers, retain timestamps and raw authentication results before drawing a conclusion; DNS caches and messages signed at different points in the rollout can expose different states.

Customer-owned and platform-owned zones need different state machines

The internal admin console should model ownership explicitly because the completion signal differs.

Zone model Who changes DNS? Useful completion evidence Common partial state
Platform-owned The platform's control plane The intended TXT record is observable through authoritative DNS Signer changed before the DNS publication completed
Customer-owned The customer's DNS operator The customer-published TXT value is observable at the delegated zone Instructions were generated, but the external zone still has the old state

For a platform-owned zone, a control-plane write can transition from requested to published only after observation. For a customer-owned zone, generating instructions should create a pending state, not a completed one. The console cannot equate “shown to the administrator” with “published on the Internet.”

I would store a rotation as desired selector, previous selector, ownership type, observation timestamps, and signer rollout status. This is intentionally boring data modeling. It also makes the operation testable without embedding DNS-provider behavior into the support application.

The retirement rule is conservative: do not remove the previous public key while any legitimate signer or queued message may still use it. The exact overlap window depends on the mail pipeline and DNS publication behavior, so it should come from measured queue age and observed resolver behavior, not a universal number copied into a runbook. The trade-off is explicit: a longer overlap gives delayed messages more time to verify, while retaining an old key longer also extends the period in which that key remains usable. Local risk policy has to choose the boundary.

Turn the repair into an evaluated deployment

The failed simple approach is a single “rotate” button that updates signing configuration and immediately reports success. It compresses several independently failing operations into one optimistic status. The better workflow has explicit phases: publish the new public key, observe it, deploy the signer change, sample real authentication results, and only then retire the old key.

For an AI-assisted admin console, keep the model away from the authority boundary. A model can summarize header evidence or explain why two selectors differ, but deterministic code should parse fields, compare expected values, and decide whether a transition is permitted. This reduces prompt cost and gives an eval harness stable assertions.

This approach has limits. It cannot reconstruct a receiver's earlier DNS view from a current lookup, and sparse samples cannot prove that every sending path moved. When those gaps matter, retain time-correlated authentication evidence and increase sampling across the actual streams; do not let the console infer completion from silence.

Useful eval cases include a new selector absent from DNS, an old selector still used by one worker pool, a verified but unaligned signing domain, and a customer-owned zone that remains pending. Score the assistant on evidence extraction and next-step accuracy. Do not score it on confident prose.

The deployment gate can be compact:

  1. The new selector is visible for the exact signing domain through authoritative DNS.
  2. Every intended mail stream emits the new selector.
  3. Receiver results show DKIM verification for representative messages, and DMARC alignment is checked separately.
  4. No sampled legitimate stream still depends on the previous selector before retirement.

Rollback should mean restoring the last known signer configuration while leaving both public keys published. Deleting records during diagnosis destroys options and can turn a partial failure into a broad one.

What should you measure before copying this rotation plan?

Measure the longest time a message can remain queued, the delay between a requested DNS change and authoritative observation, selector usage by mail stream, and pass/fail results by receiver and timestamp. Also record how often customer-owned rotations remain pending and where the handoff stalls. Those numbers determine the overlap and alert thresholds for this system.

Watch cardinality. Message IDs belong in trace samples, not metric labels; selectors, ownership type, and bounded stream names are more practical aggregation dimensions. Raw headers may contain sensitive data, so retain only what the investigation and policy require.

The durable fix is a rotation protocol with observable states, not a second attempt at the same mutation. Preserve both keys during the transition, let message evidence identify the incomplete branch, and make DNS observation the gate between intent and completion.

References

Top comments (0)