Diagnostic DNSSEC

Stale DS records left behind after a key rollover

A DNSSEC key rollover finished weeks ago. The new key works fine on its own. But someone forgot one small step at the registrar: delete the old DS record. Now resolvers see two DS entries, one that matches nothing in the zone, and validation quietly starts failing for anyone who checks DNSSEC. Here is why that old record keeps causing trouble and how to clean it up safely.

DNSSEC Python and Node.js Safe by default (dry run)
A laptop with a document
Photo by FlyD on Unsplash
The short answer

During a key rollover you publish a new DS record at the registrar but the old one never gets removed. The parent zone keeps serving both DS entries forever. A validating resolver sees the old DS key tag, looks for a DNSKEY in your zone that matches it, finds none, and cannot build a clean chain of trust, so it returns SERVFAIL for that name. The fix is to log in to the registrar (not the DNS host) and delete every DS record whose key tag has no matching, currently-signing DNSKEY, keeping only the DS that matches your live key. Full code, tests, and a dry run guard are below.

The problem in plain words

DNSSEC proves that your DNS answers were not tampered with, using a chain of signatures that runs from the root down to your zone. Part of that chain is a small record called DS, published by the registry one level up, that points at your zone's signing key, called the DNSKEY. Resolvers match the DS to the DNSKEY to prove the two agree.

When you rotate that signing key, the normal way to do it safely is to publish the new DS alongside the old one for a while, then remove the old DS once everyone has switched over. That last step, deleting the old DS at the registrar, is easy to forget, because the site itself keeps working. Nothing looks broken from a normal visit. But any resolver that tries to validate DNSSEC for your domain sees a DS record with a key tag that matches nothing in your zone, and that is enough to break the whole chain for anyone doing that check.

Registry parent zone publishes both DS records DS 55123 (old) never deleted DS 2371 (current) matches live key Child zone DNSKEY tag 2371 only no match match ok Resolver validates DS 55123 has no matching DNSKEY chain cannot be built bogus SERVFAIL for validating resolvers
The registry keeps serving the old DS record forever. It matches no DNSKEY in the child zone, so validating resolvers cannot complete the chain of trust and return SERVFAIL.

Why it happens

This is a well known failure mode of manual DNSSEC key management, which is why RFC 7344 defines an automated way to signal DS changes to the parent using CDS and CDNSKEY records, so the registrar can pick up the change itself instead of relying on someone remembering a manual cleanup step.

The key insight

A DS record is a promise: "the zone below has a DNSKEY that matches this exact key tag and digest." The registry does not check whether that promise is still true, it just keeps serving whatever DS records were entered until someone removes them. An old DS that no longer matches any live key is not harmless leftover data, it is an active claim that the resolver has to try to honor, and fails.

The fix, as a flow

The repair itself is simple: remove the DS record whose key tag has no matching DNSKEY, keeping only the DS that points at the key that is actually signing the zone right now. The part that takes care is making sure you are removing the right one, and not touching the chain while a resolver might still be caught mid-transition. Compare what the parent publishes against what the zone actually signs with, and only delete a DS once you are sure it is truly stale.

Pull DS set from the parent/registry Pull DNSKEY set from child zone's nameservers Compute expected digest per DNSKEY Diff the two key tag lists Stale DS found Delete stale DS at registrar
Compare what the parent publishes against what the zone actually signs with, then remove only the DS entries with no matching DNSKEY.

How to fix it

1

Pull the DS set the parent is publishing

Query the DS records for your domain, either from your normal resolver or straight from the TLD servers to bypass any caching. Note every key tag that comes back.

Terminal
check-ds.sh
dig ds example.com +noall +answer
# example.com.  3600  IN  DS  2371  13  2  3A1B9F...   (current key)
# example.com.  3600  IN  DS  55123 8   2  9C2E71...   (old key, still published)

dig ds example.com @a.gtld-servers.net
# hits the TLD directly, bypassing any resolver cache
2

Pull the live DNSKEY set from the child zone

Ask the zone's own authoritative nameservers what keys it is actually signing with right now, then compute the DS digest each of those keys would produce.

Terminal
check-dnskey.sh
dig dnskey example.com +noall +answer @ns1.example-host.com

dig dnskey example.com | dnssec-dsfromkey -f - example.com
# use -2 to only compute SHA-256 digests
# example.com.  IN  DS  2371  13  2  3A1B9F...
# only the live key tags print here, so 55123 is missing from this output
3

Diff the two lists and confirm the impact

Any DS key tag from step 1 that has no matching DNSKEY line in step 2 is stale. Confirm the real-world effect with a validating resolver before you touch anything.

Terminal
confirm-impact.sh
dig +dnssec example.com @1.1.1.1
# a stale-DS mismatch commonly shows SERVFAIL with no answer

delv example.com
# resolution failed: 2(SERVFAIL)   -- confirms a bogus validation result

dig +trace example.com
# the chain breaks right at the parent DS to child DNSKEY step
4

Delete the stale DS record at the registrar

Log in to the registrar's DNSSEC panel, not the DNS host, and remove the DS entry whose key tag had no match, leaving only the DS that matches the live key. If the registrar supports CDS or CDNSKEY scanning, publish a delete signal in the zone instead and let the registrar remove it automatically on its next scan.

DNS record
registrar-ds-panel.txt
# Before (registrar DNSSEC panel)
DS  2371  13  2  3A1B9F...   <- current key, keep this one
DS  55123 8   2  9C2E71...   <- stale, no matching DNSKEY, delete this one

# After
DS  2371  13  2  3A1B9F...   <- only the current key remains

# Alternative: RFC 8078 delete signal published in the child zone,
# scanned automatically by supporting registrars (Cloudflare Registrar, Gandi, etc.)
example.com. IN CDNSKEY 0 3 0 AA==
example.com. IN CDS 0 0 0 00
Never drop to zero DS mid-rollover

Always keep at least one valid DS record in place the entire time, per the RFC 7344/6781 double-DS rollover method. Wait for the old DS record's TTL to expire, typically about 1.5 times the published TTL of 3600 seconds, before deleting it, so no resolver is left holding a cached DS that no longer applies.

How to check it worked

Re-run the same queries. A clean domain returns exactly one DS key tag, and that tag has a matching DNSKEY, and end-to-end validation succeeds with the authenticated data flag set.

Terminal
verify.sh
dig ds example.com +noall +answer
# expect: only the current key tag, e.g. DS 2371 13 2 3A1B9F...

dig dnskey example.com | dnssec-dsfromkey -f - example.com
# expect: every remaining DS key tag has a matching line here

delv example.com A
# expect: a clean answer, no SERVFAIL, no "resolution failed"

dig +dnssec +multi example.com @8.8.8.8
# expect: the ad (authenticated data) flag is set in the header

dig ds example.com +trace
# expect: no red/error nodes in the delegation chain
# cross-check visually at https://dnsviz.net/d/example.com/dnssec/

The full code

Here is a small script in each language that pulls the DS set from the parent, computes the expected digest for every live DNSKEY, flags any stale DS records, and, when told to, cleans up Cloudflare-side DNSSEC signaling. Where the DS lives purely at a third-party registrar, the repair step falls back to a manual registrar-portal action.

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

stale_ds_records.py
"""Detect stale/orphaned DS records left behind after a DNSSEC key rollover,
and optionally repair Cloudflare-side DNSSEC signaling. Safe by default.
Set DRY_RUN=false to let it write.
"""
import os
import logging

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

DNS_DOMAIN = os.environ.get("DNS_DOMAIN", "example.com")
CLOUDFLARE_API_TOKEN = os.environ.get("CLOUDFLARE_API_TOKEN", "")
CLOUDFLARE_ZONE_ID = os.environ.get("CLOUDFLARE_ZONE_ID", "")
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"

CF_API = "https://api.cloudflare.com/client/v4"


def find_stale_ds(ds_records, dnskey_records):
    """Pure decision function. No I/O.

    ds_records: [{"key_tag": int, "algorithm": int, "digest_type": int,
                  "digest": str}, ...] from the parent/registry
    dnskey_records: [{"key_tag": int, "algorithm": int, "flags": int,
                      "digest": str}, ...] computed from the child zone's
                      live DNSKEYs (digest pre-hashed per digest_type)

    Returns the subset of ds_records whose (key_tag, algorithm, digest) has
    no matching entry in dnskey_records. Those are stale/orphaned DS
    records that should be removed at the registrar.
    """
    live = {
        (k["key_tag"], k["algorithm"], k["digest"].lower())
        for k in dnskey_records
    }
    stale = []
    for ds in ds_records:
        fingerprint = (ds["key_tag"], ds["algorithm"], ds["digest"].lower())
        if fingerprint not in live:
            stale.append(ds)
    return stale


def query_parent_ds(domain):
    """Query the DS RRset the parent zone is publishing. Requires network."""
    import dns.resolver

    records = []
    answer = dns.resolver.resolve(domain, "DS")
    for rdata in answer:
        records.append({
            "key_tag": rdata.key_tag,
            "algorithm": rdata.algorithm,
            "digest_type": rdata.digest_type,
            "digest": rdata.digest.hex(),
        })
    return records


def query_child_dnskeys_as_ds(domain):
    """Query the child zone's live DNSKEYs and compute the DS digest each
    one would produce, so it can be compared against the parent's DS set.
    Requires network.
    """
    import dns.resolver
    import dns.dnssec
    import dns.rdatatype

    records = []
    answer = dns.resolver.resolve(domain, "DNSKEY")
    for rdata in answer:
        ds = dns.dnssec.make_ds(domain, rdata, "SHA256")
        records.append({
            "key_tag": ds.key_tag,
            "algorithm": ds.algorithm,
            "flags": rdata.flags,
            "digest": ds.digest.hex(),
        })
    return records


def list_cloudflare_dnssec_ds(zone_id, token):
    """List DS records Cloudflare is publishing for its own DNSSEC signaling."""
    import requests

    headers = {"Authorization": f"Bearer {token}"}
    url = f"{CF_API}/zones/{zone_id}/dnssec"
    r = requests.get(url, headers=headers, timeout=30)
    r.raise_for_status()
    return r.json().get("result", {})


def repair_cloudflare_dnssec(zone_id, token):
    """Nudge Cloudflare to refresh its DNSSEC state after a rollover.

    Cloudflare manages its own DS lifecycle once DNSSEC is enabled on the
    zone; this simply re-asserts the desired state so any stale signaling
    on the Cloudflare side is refreshed. The registrar-side DS record,
    if the domain uses a third-party registrar, must be removed manually.
    """
    import requests

    if DRY_RUN:
        log.info("[dry run] would PATCH DNSSEC state for zone %s", zone_id)
        return

    headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
    url = f"{CF_API}/zones/{zone_id}/dnssec"
    requests.patch(url, headers=headers, json={"status": "active"}, timeout=30).raise_for_status()
    log.info("Refreshed Cloudflare DNSSEC state for zone %s", zone_id)


def run():
    parent_ds = query_parent_ds(DNS_DOMAIN)
    child_ds_equivalents = query_child_dnskeys_as_ds(DNS_DOMAIN)
    stale = find_stale_ds(parent_ds, child_ds_equivalents)

    if not stale:
        log.info("No stale DS records found for %s.", DNS_DOMAIN)
        return

    for ds in stale:
        log.warning(
            "Stale DS found: key_tag=%s algorithm=%s digest_type=%s (no matching DNSKEY)",
            ds["key_tag"], ds["algorithm"], ds["digest_type"],
        )

    if not (CLOUDFLARE_API_TOKEN and CLOUDFLARE_ZONE_ID):
        log.warning(
            "Stale DS records found. This zone's DS lives at the registrar; "
            "remove it there manually, or via CDS/CDNSKEY delete signaling."
        )
        return

    repair_cloudflare_dnssec(CLOUDFLARE_ZONE_ID, CLOUDFLARE_API_TOKEN)
    log.warning(
        "Cloudflare-side DNSSEC state refreshed, but the registrar-side DS "
        "record still needs manual removal if the registrar is third-party."
    )


if __name__ == "__main__":
    run()
stale-ds-records.js
/**
 * Detect stale/orphaned DS records left behind after a DNSSEC key rollover,
 * and optionally repair Cloudflare-side DNSSEC signaling. Safe by default.
 * Set DRY_RUN=false to let it write.
 */
import { pathToFileURL } from "node:url";

const DNS_DOMAIN = process.env.DNS_DOMAIN || "example.com";
const CLOUDFLARE_API_TOKEN = process.env.CLOUDFLARE_API_TOKEN || "";
const CLOUDFLARE_ZONE_ID = process.env.CLOUDFLARE_ZONE_ID || "";
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
const CF_API = "https://api.cloudflare.com/client/v4";

/**
 * Pure decision function. No I/O.
 *
 * dsRecords: [{ keyTag, algorithm, digestType, digest }, ...] from the
 *            parent/registry
 * dnskeyRecords: [{ keyTag, algorithm, flags, digest }, ...] computed from
 *                the child zone's live DNSKEYs (digest pre-hashed per
 *                digestType for comparison)
 *
 * Returns the subset of dsRecords whose (keyTag, algorithm, digest) has no
 * matching entry in dnskeyRecords. Those are stale/orphaned DS records
 * that should be removed at the registrar.
 */
export function findStaleDs(dsRecords, dnskeyRecords) {
  const live = new Set(
    dnskeyRecords.map((k) => `${k.keyTag}:${k.algorithm}:${k.digest.toLowerCase()}`)
  );
  return dsRecords.filter((ds) => {
    const fingerprint = `${ds.keyTag}:${ds.algorithm}:${ds.digest.toLowerCase()}`;
    return !live.has(fingerprint);
  });
}

/** Query the DS RRset the parent zone is publishing. Requires network. */
async function queryParentDs(domain) {
  const dns = await import("node:dns/promises");
  const results = await dns.resolveDs(domain);
  return results.map((r) => ({
    keyTag: r.keyTag,
    algorithm: r.algorithm,
    digestType: r.digestType,
    digest: r.digest.toString("hex"),
  }));
}

/**
 * Query the child zone's live DNSKEYs. Node's built-in dns module does not
 * compute DS digests, so this reports the raw DNSKEY set for comparison
 * against a precomputed digest, matching the pure function's expected shape.
 * Requires network.
 */
async function queryChildDnskeys(domain) {
  const dns = await import("node:dns/promises");
  const results = await dns.resolveAny(domain);
  return results
    .filter((r) => r.type === "DNSKEY")
    .map((r) => ({
      keyTag: r.keyTag,
      algorithm: r.algorithm,
      flags: r.flags,
      digest: r.digest ? r.digest.toString("hex") : "",
    }));
}

/** List DS records Cloudflare is publishing for its own DNSSEC signaling. */
async function listCloudflareDnssecDs(zoneId, token) {
  const url = `${CF_API}/zones/${zoneId}/dnssec`;
  const res = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
  if (!res.ok) throw new Error(`Cloudflare DNSSEC read failed: ${res.status}`);
  const body = await res.json();
  return body.result || {};
}

/**
 * Nudge Cloudflare to refresh its DNSSEC state after a rollover. Cloudflare
 * manages its own DS lifecycle once DNSSEC is enabled on the zone; this
 * simply re-asserts the desired state. The registrar-side DS record, if
 * the domain uses a third-party registrar, must be removed manually.
 */
async function repairCloudflareDnssec(zoneId, token) {
  if (DRY_RUN) {
    console.log(`[dry run] would PATCH DNSSEC state for zone ${zoneId}`);
    return;
  }

  const headers = { Authorization: `Bearer ${token}`, "Content-Type": "application/json" };
  const url = `${CF_API}/zones/${zoneId}/dnssec`;
  const res = await fetch(url, { method: "PATCH", headers, body: JSON.stringify({ status: "active" }) });
  if (!res.ok) throw new Error(`Cloudflare DNSSEC patch failed: ${res.status}`);
  console.log(`Refreshed Cloudflare DNSSEC state for zone ${zoneId}`);
}

async function run() {
  const parentDs = await queryParentDs(DNS_DOMAIN);
  const childDnskeys = await queryChildDnskeys(DNS_DOMAIN);
  const stale = findStaleDs(parentDs, childDnskeys);

  if (stale.length === 0) {
    console.log(`No stale DS records found for ${DNS_DOMAIN}.`);
    return;
  }

  for (const ds of stale) {
    console.warn(
      `Stale DS found: keyTag=${ds.keyTag} algorithm=${ds.algorithm} digestType=${ds.digestType} (no matching DNSKEY)`
    );
  }

  if (!(CLOUDFLARE_API_TOKEN && CLOUDFLARE_ZONE_ID)) {
    console.warn(
      "Stale DS records found. This zone's DS lives at the registrar; " +
      "remove it there manually, or via CDS/CDNSKEY delete signaling."
    );
    return;
  }

  await repairCloudflareDnssec(CLOUDFLARE_ZONE_ID, CLOUDFLARE_API_TOKEN);
  console.warn(
    "Cloudflare-side DNSSEC state refreshed, but the registrar-side DS " +
    "record still needs manual removal if the registrar is third-party."
  );
}

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 matching rule is the part worth testing, because it decides which DS records get flagged as stale. It takes plain lists of dictionaries, no network, no DNS lookups, so the tests run in milliseconds.

test_stale_ds_find.py
from stale_ds_records import find_stale_ds


def ds(**over):
    base = {"key_tag": 2371, "algorithm": 13, "digest_type": 2, "digest": "3a1b9f"}
    base.update(over)
    return base


def dnskey(**over):
    base = {"key_tag": 2371, "algorithm": 13, "flags": 257, "digest": "3a1b9f"}
    base.update(over)
    return base


def test_no_stale_when_ds_matches_dnskey():
    assert find_stale_ds([ds()], [dnskey()]) == []


def test_flags_ds_with_no_matching_dnskey():
    old_ds = ds(key_tag=55123, algorithm=8, digest="9c2e71")
    result = find_stale_ds([ds(), old_ds], [dnskey()])
    assert result == [old_ds]


def test_flags_all_when_no_dnskeys_present():
    old_ds = ds()
    assert find_stale_ds([old_ds], []) == [old_ds]


def test_case_insensitive_digest_comparison():
    upper_ds = ds(digest="3A1B9F")
    assert find_stale_ds([upper_ds], [dnskey()]) == []
stale-ds-find.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { findStaleDs } from "./stale-ds-records.js";

const ds = (over = {}) => ({ keyTag: 2371, algorithm: 13, digestType: 2, digest: "3a1b9f", ...over });
const dnskey = (over = {}) => ({ keyTag: 2371, algorithm: 13, flags: 257, digest: "3a1b9f", ...over });

test("no stale when ds matches dnskey", () => {
  assert.deepEqual(findStaleDs([ds()], [dnskey()]), []);
});

test("flags ds with no matching dnskey", () => {
  const oldDs = ds({ keyTag: 55123, algorithm: 8, digest: "9c2e71" });
  const result = findStaleDs([ds(), oldDs], [dnskey()]);
  assert.deepEqual(result, [oldDs]);
});

test("flags all when no dnskeys present", () => {
  const oldDs = ds();
  assert.deepEqual(findStaleDs([oldDs], []), [oldDs]);
});

test("case insensitive digest comparison", () => {
  const upperDs = ds({ digest: "3A1B9F" });
  assert.deepEqual(findStaleDs([upperDs], [dnskey()]), []);
});

Case studies

Registrar migration

The transfer that dragged an old DS record along

A team rotated their DNSSEC key at their DNS host and confirmed the site worked fine. Months later they transferred the domain to a new registrar, and the transfer tool copied over every DS record it found, including the one from before the rollover that nobody had removed. The new registrar happily kept publishing both.

Nothing looked wrong until a partner's monitoring system, which does validate DNSSEC, started reporting SERVFAIL for the domain. Pulling the DS set and diffing it against the live DNSKEY showed the leftover key tag immediately, and deleting it at the new registrar's DNSSEC panel fixed it within the hour.

Manual key ceremony

The rollover checklist that stopped one step early

An internal runbook for DNSSEC key rollovers listed generate key, publish DNSKEY, submit new DS, verify resolution. It never mentioned removing the old DS once the new one was confirmed. Every rollover since then left one more stale DS record sitting at the registrar, and after three rollovers the DNSSEC panel showed three DS entries where only one was ever needed.

Running the digest comparison against the live DNSKEY flagged two of the three as stale in seconds. The team removed both, kept the current one, and added the missing step back into the runbook.

What good looks like

A healthy, rolled-over zone publishes exactly one DS record set at the parent, and every key tag in it has a matching, currently-signing DNSKEY in the child zone. A validating resolver builds the chain of trust cleanly, gets the authenticated data flag set, and never returns SERVFAIL because of a leftover key from a rollover that finished long ago.

FAQ

Why does my domain fail DNSSEC validation after I rotated my DNSSEC key?

The registrar is still publishing the old DS record from before the rollover, alongside the new one. A validating resolver sees a DS key tag with no matching, currently-signing DNSKEY in your zone and cannot build a clean chain of trust, so it returns SERVFAIL or marks the zone bogus.

Is it safe to delete an old DS record myself?

Yes, as long as you first confirm the remaining DS record matches a DNSKEY that is actually live and signing in your zone right now. Never delete every DS at once mid-rollover. Keep at least one valid DS in place the whole time, and wait roughly 1.5 times the old record's TTL before removing it so no resolver is caught mid-cache.

How do I avoid this happening on the next key rollover?

Use the RFC 7344 double-DS rollover method and publish a CDS or CDNSKEY delete signal in your zone once the new key is live. Registrars that scan for CDS/CDNSKEY, including Cloudflare Registrar and Gandi, will then remove the old DS automatically on their next scan, so nobody has to remember a manual cleanup step.

Related field notes

Citations

On the problem:

  1. RFC 7344: Automating DNSSEC Delegation Trust Maintenance. rfc-editor.org/rfc/rfc7344
  2. NameSilo: Why DNSSEC Causes SERVFAIL Errors and How to Fix Them (DS Mismatch). namesilo.com/blog
  3. Cloudflare DNS docs: Validation and key management. developers.cloudflare.com/dns/dnssec/validation-and-key-management

On the solution:

  1. Cloudflare DNS docs: DNSSEC migration tutorial (multi-signer, DS/DNSKEY handling). developers.cloudflare.com/dns/dnssec/dnssec-active-migration
  2. NameSilo: DS Record Mistakes, Fixing Broken Delegation After a DNSSEC Change. namesilo.com/blog
  3. nixCraft: How to test and validate DNSSEC using dig command line. cyberciti.biz/faq

Stuck on a tricky one?

If you have a DNS, email deliverability, or certificate problem 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 DNSSEC chain?

If this saved you a validation outage or a confusing SERVFAIL, 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