Reconciler Email Authentication

DKIM public key stale after rotation

You rotated your DKIM signing key. Nothing else changed. But now every outgoing message comes back dkim=fail instead of dkim=pass. The DNS record is not missing, it resolves fine, it just still holds the old public key that no longer matches what your mail server is signing with. Here is why that happens and how to roll a key over without breaking mail in the middle.

dig and openssl Python and Node.js Fixable through the DNS API
The word email in tiles
Photo by Miguel Angel Padrinan Alba on Unsplash
The short answer

Your mail platform started signing with a new DKIM private key, but the matching public key was never published to the TXT record at the selector named in the DKIM-Signature header. Receivers still fetch the old public key from DNS, it does not match the new signature, so every message fails as dkim=fail, a key mismatch, not a missing record. Publish the new key under a new selector, switch signing to it, keep the old selector alive for a day or two, then retire it. Full check script and code below.

The problem in plain words

DKIM works by pairs. A private key stays secret on the mail server and signs every outgoing message. A public key sits in DNS so anyone receiving the mail can check the signature against it. Both halves have to match, always.

When someone rotates the private key on the mail platform, that is only half the job. The new private key needs a new public key in DNS before it starts signing anything. If the platform starts signing with the new key while DNS still answers with the old public key, every single message fails verification. The DNS record is not broken. It resolves. It is just the wrong key for what is being signed now.

This usually happens in one of two ways: someone swaps the private key on the mail server as one change and forgets DNS is a second change, or someone rotates selectors out of order, retiring the old one before the new one is live and checked.

Mail server signs with new private key DNS TXT record still holds old public key Keys match? no, mismatch dkim=fail signature mismatch Mail at risk
DNS answers with a real, non-empty public key. It is just the old one, so it never matches a signature made with the new private key.

Why it happens

RFC 6376 defines DKIM as this exact pairing: a selector in the signature names the DNS location of the public key, and verification only works when that public key matches the private key that made the signature. A few common ways teams break that pairing during rotation:

M3AAWG's key rotation guidance calls this out directly: key rotation is a DNS change and a mail platform change that must happen in the right order, with an overlap window, not a single flip of a switch.

The key insight

A missing DKIM record and a stale DKIM record look similar to an angry inbox admin, but they are opposite failures. Missing means DNS has nothing to compare against, so the result is none or permfail. Stale means DNS has a perfectly valid answer, it is just the wrong key, so the result is dkim=fail with a real signature mismatch. Fixing a stale key means publishing the correct value, not just publishing something.

The fix, as a flow

The safe pattern is to never edit a selector's record in place while mail is actively signing with it. Instead, treat rotation as adding a brand new selector, moving signing over to it, and only then retiring the old one once nothing depends on it anymore.

Publish new key at new selector name Verify new record matches deployed key Switch signing to the new selector Keep old selector resolving 24 to 48h for queued mail Retire old selector
New selector goes live and gets checked before the mail server ever signs with it. The old selector is retired only after the overlap window, once nothing needs it.

How to fix it

1

Find the selector that is actually signing mail

Open a DKIM-Signature header from a real message your mail server just sent. The s= tag is the selector, and d= is the signing domain. That selector name is what the receiver looks up in DNS, not whatever name you think should be active.

DKIM-Signature header
from a sent message
DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed;
    d=example.com; s=sel2; t=1752123456;
    h=from:to:subject:date; bh=...; b=...
2

Query the live public key in DNS

Ask DNS directly for the TXT record at that selector. A healthy record is non-empty and starts with v=DKIM1 followed by a long p= value, the base64 public key.

Terminal
check the DNS record
dig +short TXT sel2._domainkey.example.com

"v=DKIM1; k=rsa; p=MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQC4v..."
3

Compare it against the key actually deployed on the mail server

Derive the public key from the private key file that the mail server is signing with right now, and compare the base64 output against the p= value from dig. If they differ, DNS is stale, not missing, and this is the exact mismatch causing dkim=fail.

Terminal
derive the public key from the deployed private key
openssl rsa -in dkim_private.pem -pubout -outform DER 2>/dev/null | openssl base64 -A

# Compare this output, character for character, against the p= value
# returned by dig above. Any difference means DNS is stale.
4

Publish the new key under a new selector, never overwrite the old one live

Add a brand new TXT record at the new selector name with the correct public key. Do not touch the old selector's record yet, in-transit mail signed with the old key still needs it to resolve.

DNS record
new selector TXT record
Type:  TXT
Host:  sel2._domainkey.example.com
Value: "v=DKIM1; k=rsa; p=<base64-of-new-public-key>"
TTL:   3600
5

Switch the mail platform to sign with the new selector

Only after step 4 resolves correctly, point the mail server or ESP at the new selector so its s= tag becomes sel2. Keep sel1._domainkey.example.com resolving unchanged for at least 24 to 48 hours so queued and in-transit mail signed with sel1 still verifies.

6

Retire the old selector once it is confirmed unused

After the overlap window, once you have confirmed nothing is still signing with sel1, delete its TXT record, or set its p= tag to empty to formally revoke it. An empty p= means revoked on purpose. It is different from staleness and different from a missing record.

DNS record
revoke the old selector
Type:  TXT
Host:  sel1._domainkey.example.com
Value: "v=DKIM1; p="
Same selector, in place rotation

If your setup requires keeping the same selector name and rotating the key value in place, update the existing TXT record's p= tag to the new key's base64 value at the exact moment the mail server begins signing with the new private key. Never let the old p= linger once the new private key is live, and never flip DNS before the new private key is actually deployed either. Either order creates a window where nothing matches.

How to check it worked

Confirm the fix from both the DNS side and the mail side, and rule out propagation lag before you call anything broken.

Terminal
verify across resolvers and against Authentication-Results
# 1. The new selector resolves with the new key everywhere, not just at your authoritative NS
dig @1.1.1.1 +short TXT sel2._domainkey.example.com
dig @8.8.8.8 +short TXT sel2._domainkey.example.com

# 2. Confirm the base64 in DNS matches the deployed private key, byte for byte
openssl rsa -in new_private.pem -pubout -outform DER | openssl base64 -A

# 3. Send a fresh test email through the production mail path, then check
#    the Authentication-Results header on the receiving side. A good result:
Authentication-Results: mx.example.net;
    dkim=pass header.d=example.com header.s=sel2

# A bad result still shows dkim=fail (signature mismatch) even though the
# TXT record resolves fine, which means the DNS value and the signing key
# still do not match.

The full code

Below is a small script that resolves the live selector's TXT record, pulls out the p= value, and diffs it against the public key derived from the currently deployed private key. On a mismatch, it can publish the correct record to a new selector, or update an existing one, through the Cloudflare DNS API. It stays in dry run until you turn writes on.

View this code on GitHub Full runnable folder with tests in the dns-fixes repo.

dkim_key_rotation_check.py
"""Detect a DKIM public key that is stale after a private key rotation and
repair it via Cloudflare. Safe to run on a schedule. Stays in dry run
until DRY_RUN=false.
"""
import os
import logging

logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("dkim_key_rotation_check")

MISSING = "missing"


def dkim_key_mismatch(published_txt: str, deployed_pubkey_b64: str):
    """Pure decision function. No network, no I/O.

    Parses the p= tag out of a published DKIM TXT record value, handling
    the quoted/concatenated chunk style DNS providers sometimes return per
    RFC 6376, and compares it against the base64 public key actually
    derived from the deployed private key.

    Returns True if the keys differ (stale), False if they match (ok),
    or the MISSING sentinel string if there is no DKIM record at all so
    callers can branch on mismatch vs missing vs revoked.
    """
    if not published_txt:
        return MISSING

    # Join quoted/concatenated TXT chunks into one string, RFC 6376 style.
    joined = "".join(part.strip().strip('"') for part in published_txt.split('" "'))
    joined = joined.strip().strip('"')

    p_value = None
    for tag in joined.split(";"):
        tag = tag.strip()
        if tag.startswith("p="):
            p_value = tag[2:].strip()
            break

    if p_value is None:
        return MISSING
    if p_value == "":
        # Empty p= means the key was deliberately revoked, not stale.
        return False

    return p_value != deployed_pubkey_b64.strip()


def run():
    # Imported lazily so the pure function above can be tested with no
    # network or crypto libraries installed at all.
    import subprocess
    import dns.resolver
    import requests

    domain = os.environ["DNS_DOMAIN"]
    selector = os.environ["DKIM_SELECTOR"]
    private_key_path = os.environ["DKIM_PRIVATE_KEY_PATH"]
    ttl = int(os.environ.get("RECORD_TTL", "3600"))
    dry_run = os.environ.get("DRY_RUN", "true").lower() == "true"

    zone_id = os.environ["CLOUDFLARE_ZONE_ID"]
    api_token = os.environ["CLOUDFLARE_API_TOKEN"]

    name = f"{selector}._domainkey.{domain}"

    try:
        answer = dns.resolver.resolve(name, "TXT")
        published_txt = "".join(
            chunk.decode() if isinstance(chunk, bytes) else chunk
            for rdata in answer for chunk in rdata.strings
        )
    except (dns.resolver.NXDOMAIN, dns.resolver.NoAnswer):
        published_txt = ""

    deployed_pubkey_b64 = subprocess.run(
        ["openssl", "rsa", "-in", private_key_path, "-pubout", "-outform", "DER"],
        capture_output=True, check=True,
    ).stdout
    deployed_pubkey_b64 = subprocess.run(
        ["openssl", "base64", "-A"], input=deployed_pubkey_b64,
        capture_output=True, check=True,
    ).stdout.decode().strip()

    result = dkim_key_mismatch(published_txt, deployed_pubkey_b64)

    if result == MISSING:
        log.warning("No DKIM record found at %s. This is a missing record, not staleness.", name)
        return
    if result is False:
        log.info("DKIM key at %s matches the deployed private key. Nothing to do.", name)
        return

    log.warning("DKIM key at %s is stale: DNS still serves the old public key.", name)
    correct_value = f"v=DKIM1; k=rsa; p={deployed_pubkey_b64}"

    log.info("%s update TXT record for %s.", "Would" if dry_run else "Will", name)
    if dry_run:
        return

    lookup = requests.get(
        f"https://api.cloudflare.com/client/v4/zones/{zone_id}/dns_records",
        headers={"Authorization": f"Bearer {api_token}"},
        params={"type": "TXT", "name": name},
        timeout=30,
    )
    lookup.raise_for_status()
    existing = lookup.json().get("result", [])

    payload = {"type": "TXT", "name": name, "content": correct_value, "ttl": ttl}
    if existing:
        record_id = existing[0]["id"]
        resp = requests.put(
            f"https://api.cloudflare.com/client/v4/zones/{zone_id}/dns_records/{record_id}",
            headers={"Authorization": f"Bearer {api_token}", "Content-Type": "application/json"},
            json=payload, timeout=30,
        )
    else:
        resp = requests.post(
            f"https://api.cloudflare.com/client/v4/zones/{zone_id}/dns_records",
            headers={"Authorization": f"Bearer {api_token}", "Content-Type": "application/json"},
            json=payload, timeout=30,
        )
    resp.raise_for_status()
    log.info("Published the correct DKIM public key at %s", name)


if __name__ == "__main__":
    run()
dkim-key-rotation-check.js
/**
 * Detect a DKIM public key that is stale after a private key rotation and
 * repair it via Cloudflare. Safe to run on a schedule. Stays in dry run
 * until DRY_RUN=false.
 */
import { pathToFileURL } from "node:url";

export const MISSING = "missing";

export function dkimKeyMismatch(publishedTxt, deployedPubkeyB64) {
  // Pure decision function. No network, no I/O.
  //
  // Parses the p= tag out of a published DKIM TXT record value, handling
  // the quoted/concatenated chunk style DNS providers sometimes return per
  // RFC 6376, and compares it against the base64 public key actually
  // derived from the deployed private key.
  //
  // Returns true if the keys differ (stale), false if they match (ok),
  // or the MISSING sentinel string if there is no DKIM record at all so
  // callers can branch on mismatch vs missing vs revoked.
  if (!publishedTxt) return MISSING;

  const joined = publishedTxt
    .split('" "').map((part) => part.trim().replace(/^"|"$/g, "")).join("")
    .trim().replace(/^"|"$/g, "");

  let pValue = null;
  for (const rawTag of joined.split(";")) {
    const tag = rawTag.trim();
    if (tag.startsWith("p=")) {
      pValue = tag.slice(2).trim();
      break;
    }
  }

  if (pValue === null) return MISSING;
  if (pValue === "") return false; // Empty p= means deliberately revoked, not stale.

  return pValue !== deployedPubkeyB64.trim();
}

export async function run() {
  // Imported lazily so the pure function above can be tested with no
  // network, DNS, or crypto modules touched at all.
  const dns = await import("node:dns");
  const { execFileSync } = await import("node:child_process");

  const domain = process.env.DNS_DOMAIN;
  const selector = process.env.DKIM_SELECTOR;
  const privateKeyPath = process.env.DKIM_PRIVATE_KEY_PATH;
  const ttl = Number(process.env.RECORD_TTL || 3600);
  const dryRun = (process.env.DRY_RUN || "true").toLowerCase() === "true";

  const zoneId = process.env.CLOUDFLARE_ZONE_ID;
  const apiToken = process.env.CLOUDFLARE_API_TOKEN;

  const name = `${selector}._domainkey.${domain}`;

  let publishedTxt = "";
  try {
    const records = await dns.promises.resolveTxt(name);
    publishedTxt = records.map((chunks) => chunks.join("")).join("");
  } catch (err) {
    if (err.code !== "ENOTFOUND" && err.code !== "ENODATA") throw err;
  }

  const derDer = execFileSync("openssl", ["rsa", "-in", privateKeyPath, "-pubout", "-outform", "DER"]);
  const deployedPubkeyB64 = execFileSync("openssl", ["base64", "-A"], { input: derDer }).toString().trim();

  const result = dkimKeyMismatch(publishedTxt, deployedPubkeyB64);

  if (result === MISSING) {
    console.warn(`No DKIM record found at ${name}. This is a missing record, not staleness.`);
    return;
  }
  if (result === false) {
    console.log(`DKIM key at ${name} matches the deployed private key. Nothing to do.`);
    return;
  }

  console.warn(`DKIM key at ${name} is stale: DNS still serves the old public key.`);
  const correctValue = `v=DKIM1; k=rsa; p=${deployedPubkeyB64}`;

  console.log(`${dryRun ? "Would" : "Will"} update TXT record for ${name}.`);
  if (dryRun) return;

  const lookupRes = await fetch(
    `https://api.cloudflare.com/client/v4/zones/${zoneId}/dns_records?type=TXT&name=${encodeURIComponent(name)}`,
    { headers: { Authorization: `Bearer ${apiToken}` } },
  );
  if (!lookupRes.ok) throw new Error(`Cloudflare lookup returned ${lookupRes.status}`);
  const lookupBody = await lookupRes.json();
  const existing = lookupBody.result || [];

  const payload = { type: "TXT", name, content: correctValue, ttl };
  const url = existing.length
    ? `https://api.cloudflare.com/client/v4/zones/${zoneId}/dns_records/${existing[0].id}`
    : `https://api.cloudflare.com/client/v4/zones/${zoneId}/dns_records`;
  const method = existing.length ? "PUT" : "POST";

  const res = await fetch(url, {
    method,
    headers: { Authorization: `Bearer ${apiToken}`, "Content-Type": "application/json" },
    body: JSON.stringify(payload),
  });
  if (!res.ok) throw new Error(`Cloudflare API returned ${res.status}`);
  console.log(`Published the correct DKIM public key at ${name}`);
}

if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
  run().catch((err) => { console.error(err); process.exit(1); });
}

Add a test

The decision function is the part worth testing on its own, because it decides whether a real production DNS record gets rewritten. It takes plain strings in and returns a plain result, no DNS lookups and no Cloudflare account needed.

test_dkim_rotation.py
from dkim_key_rotation_check import dkim_key_mismatch, MISSING


def test_missing_when_no_record():
    assert dkim_key_mismatch("", "AAA123") == MISSING


def test_missing_when_no_p_tag_at_all():
    assert dkim_key_mismatch("v=DKIM1; k=rsa", "AAA123") == MISSING


def test_revoked_when_p_is_empty():
    assert dkim_key_mismatch("v=DKIM1; p=", "AAA123") is False


def test_mismatch_when_keys_differ():
    published = 'v=DKIM1; k=rsa; p=OLDKEY000'
    assert dkim_key_mismatch(published, "NEWKEY111") is True


def test_ok_when_keys_match():
    published = "v=DKIM1; k=rsa; p=SAMEKEY42"
    assert dkim_key_mismatch(published, "SAMEKEY42") is False


def test_handles_concatenated_quoted_chunks():
    published = '"v=DKIM1; k=rsa; " "p=SPLITKEY99"'
    assert dkim_key_mismatch(published, "SPLITKEY99") is False
dkim-key-rotation-check.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { dkimKeyMismatch, MISSING } from "./dkim-key-rotation-check.js";

test("missing when no record", () => {
  assert.equal(dkimKeyMismatch("", "AAA123"), MISSING);
});

test("missing when no p tag at all", () => {
  assert.equal(dkimKeyMismatch("v=DKIM1; k=rsa", "AAA123"), MISSING);
});

test("revoked when p is empty", () => {
  assert.equal(dkimKeyMismatch("v=DKIM1; p=", "AAA123"), false);
});

test("mismatch when keys differ", () => {
  const published = "v=DKIM1; k=rsa; p=OLDKEY000";
  assert.equal(dkimKeyMismatch(published, "NEWKEY111"), true);
});

test("ok when keys match", () => {
  const published = "v=DKIM1; k=rsa; p=SAMEKEY42";
  assert.equal(dkimKeyMismatch(published, "SAMEKEY42"), false);
});

test("handles concatenated quoted chunks", () => {
  const published = '"v=DKIM1; k=rsa; " "p=SPLITKEY99"';
  assert.equal(dkimKeyMismatch(published, "SPLITKEY99"), false);
});

Case studies

ESP migration

The provider that rotated on its own schedule

A team moved outbound mail to a new ESP. The ESP rotated its DKIM signing key automatically during onboarding and told the team to "add the DNS record," but the record they were given did not match the selector the ESP actually ended up signing with after a later config change.

Pulling the s= tag from a real sent message and diffing it against the deployed key found the mismatch in minutes. Publishing the correct key under the right selector fixed dkim=fail without a single support ticket to the ESP.

Security rotation

The scheduled rotation that skipped the overlap window

A security policy required rotating the DKIM private key every ninety days. The runbook updated the mail server first, then queued a ticket to update DNS "by end of day." Mail sent in that gap failed at every receiver that checked DKIM strictly.

Moving to a new selector for every rotation, verified in DNS before the switch, removed the gap entirely. The old selector now stays alive for two full days after every rotation before it is revoked.

What good looks like

After this, a key rotation is boring: a new selector goes into DNS, gets checked against the deployed private key, then and only then does the mail server start signing with it, and the old selector sticks around for a day or two before it is revoked. dkim=pass never has a gap.

FAQ

Why does DKIM fail right after we rotated our signing key?

Your mail server or ESP started signing with a new private key, but the matching public key was never published to the TXT record at the selector DNS uses. Receivers still see the old public key, it does not match the new signature, and every message comes back as dkim=fail, a key mismatch rather than a missing record.

How is this different from a missing DKIM selector record?

A missing selector record returns NXDOMAIN or no answer, so verifiers report none or permfail because there is nothing to check against. A stale key after rotation still returns a normal, non-empty TXT record, it just holds the old public key. Verifiers get a real answer and compare it, so the result is dkim=fail, a mismatch, not a missing record.

Is it safe to overwrite the old selector's TXT record right away?

Not immediately. Mail that was queued or is still in transit may have been signed with the old key, so keep the old selector resolving unchanged for 24 to 48 hours after the new one goes live. Publish the new key under a new selector, switch signing to it, then retire the old selector once you confirm nothing is using it.

Related field notes

Citations

On the problem:

  1. RFC 6376: DomainKeys Identified Mail (DKIM) Signatures. datatracker.ietf.org/doc/html/rfc6376
  2. RFC 8301: Cryptographic Algorithm and Key Usage Update to DKIM. datatracker.ietf.org/doc/html/rfc8301
  3. M3AAWG DKIM Key Rotation Best Common Practices. m3aawg.org/DKIMKeyRotation

On the solution:

  1. DKIM key rotation: a safe, zero-downtime playbook. willitinbox.com/blog/dkim-key-rotation
  2. Verify DKIM with dig and online tools. support.dnsimple.com/articles/verify-dkim
  3. Cloudflare DNS records API documentation. developers.cloudflare.com/api

Stuck on a tricky one?

If you have a DNS or email authentication problem that you would rather hand off, this is the kind of work I do. Message me and we can work through it together.

Contact me on LinkedIn

Did this fix your DKIM failures?

If this saved you a rotation gone wrong or a pile of bounced mail, you can buy me a coffee. It is the best way to keep these field notes free and growing.

Buy me a coffee on Ko-fi

Back to all DNS and domains field notes