Diagnostic DNSSEC
DNSSEC stuck pending during a domain transfer-in
You moved a domain to a new registrar, DNSSEC was working fine before the move, and now the registrar's dashboard just says "DNSSEC pending" and will not clear, sometimes for weeks. Your DNS zone is still signed the whole time. What is actually missing is one small record at the registry that only the registrar can add. Here is why that happens and a small script that checks for it.
DNSSEC pending means the new registrar has not published a DS record at the registry that matches the key your DNS host is signing with. The DS record lives one level up, in the parent zone, and only the registrar can put it there, either by hand or by picking up CDS and CDNSKEY records your DNS host publishes. If the losing registrar left a stale DS behind, the new registrar's scanner has not run yet, or the CDS and CDNSKEY records do not match what it expects, the DS never appears and the status sits on pending. Run a small Python or Node.js checker that reads the CDS and CDNSKEY at your DNS host and the DS at the registry, compares them, and tells you plainly whether this is still normal or genuinely stuck. Full code, tests, and the fix are below.
The problem in plain words
DNSSEC works with two pieces that live in two different places. Your DNS host signs your zone and publishes the keys that prove the signatures are real. The registry, one level up, publishes a short fingerprint of that key called a DS record. A resolver checking your domain follows the trust from the registry's DS record down to your zone's key. If those two pieces do not match, or the DS record is missing, the chain of trust breaks.
When you transfer a domain to a new registrar, your DNS host usually keeps signing the zone the whole time without any interruption. The part that lags behind is the DS record at the registry, because that is a registrar action, not a DNS zone action. The new registrar has to notice the key and submit it. Until it does, the registry's DS record is missing or wrong, so the registrar's own dashboard correctly reports "pending," because from its side, nothing has arrived yet.
Why it happens
RFC 7344 and RFC 8078 describe how a registrar is supposed to pick up CDS and CDNSKEY records from a zone and turn them into a DS record automatically. In practice, a transfer-in interrupts that handoff in a few common ways:
- The losing registrar left a stale DS record in place, and the new registrar has not replaced it yet, so the registry still has the wrong fingerprint.
- The new registrar's automatic CDS and CDNSKEY scanner simply has not run yet. Most registrars only poll every 24 to 48 hours, not instantly.
- The CDS or CDNSKEY records published at the DNS host do not match what the registrar expects, often because the key was rotated around the same time as the transfer.
- The zone stopped publishing CDS and CDNSKEY records entirely during the move, so the registrar has nothing to pick up even if it is looking.
Whatever the cause, the result looks the same from the outside: the zone is signed, the dashboard says pending, and nothing changes on its own for a long time.
DNSSEC pending is not a DNS problem, it is a registry problem. Your zone's signatures and keys can be perfectly correct while the one thing missing, a matching DS record at the parent zone, sits entirely outside your DNS host's control. Fixing it means acting at the registrar, either by waiting for its scanner or by pasting the DS values in yourself.
The fix, as a flow
First confirm the zone really is signed and publishing the records a registrar needs to pick up. Then check what the registry actually has for the DS record. If those two disagree, or the registry has nothing at all, go into the new registrar's dashboard and either let it auto add the DS record from CDS and CDNSKEY, or copy the exact DS values in by hand.
How to fix it
Confirm the zone is actually signed
Ask the domain's own authoritative nameserver for its DNSKEY and CDS records. A good result returns a key signing key and zone signing key, plus a CDS record with the digest the registrar is supposed to pick up.
dig +short DNSKEY example.com @<authoritative-ns>
dig +short CDS example.com @<authoritative-ns>
dig +short CDNSKEY example.com @<authoritative-ns>
# good result: DNSKEY returns a KSK and a ZSK, and CDS/CDNSKEY
# return the digest and key the registrar should be scanning for
Check what the registry actually has for the DS record
Query the TLD directly, or ask a normal recursive resolver, or check the registry's RDAP endpoint. A bad result is an empty answer, or a DS record whose key tag and digest do not match the CDS you saw in the last step.
# from the TLD directly, for a .com domain
dig +short DS example.com @a.gtld-servers.net
# from a normal recursive resolver
dig +short DS example.com
# from the registry's RDAP endpoint
curl -s https://rdap.verisign.com/com/v1/domain/example.com | jq '.secureDNS'
Validate end to end
Ask a validating resolver for the domain with DNSSEC turned on and look for the ad flag, or use delv, which explains a broken chain of trust in plain words instead of just a missing flag.
dig +dnssec +multi example.com
# bad result: the flags line is missing "ad" (Authenticated Data)
delv example.com
# bad result: "resolution failed: insecurity proof failed"
# or "no valid DS"
Add the DS record at the new registrar
This part is a registrar-portal action, not something a DNS zone edit can do. Open the domain's DNSSEC settings at the new registrar. If it offers automatic DS pickup, confirm the DNS host is publishing CDS and CDNSKEY records that match its current key, since most registrars poll every 24 to 48 hours. If automatic pickup has clearly stalled, copy the exact DS values from the DNS host's DNSSEC panel and paste them into the registrar's "Add DS record" form by hand.
Key Tag: 2371
Algorithm: 13 (ECDSAP256SHA256)
Digest Type: 2 (SHA-256)
Digest: f5b2a...c91e
# these four values must exactly match what the zone's DNSSEC
# panel at the DNS host shows for the current key
Clear an orphaned DS record if one is blocking it
If the losing registrar left a stale DS record behind and the registry will not accept a new one on top of it, contact that registrar or the new registrar's support to have the orphaned DS removed from the registry before adding the correct one.
How to check it worked
Query the registry again, confirm the ad flag appears, and confirm the registrar dashboard clears the pending banner.
dig +short DS example.com @8.8.8.8
# good result: a non-empty DS record whose key tag matches
# the CDS/DNSKEY from the DNS host
dig +dnssec example.com A @8.8.8.8
# good result: the flags line includes "ad"
delv example.com
# good result: a fully validated answer, no "insecurity proof failed"
curl -s https://rdap.verisign.com/com/v1/domain/example.com | jq '.secureDNS.delegationSigned'
# good result: true, with the matching dsData shown
The full code
Here is a complete checker in one file for each language. It reads CDS and CDNSKEY at the child's authoritative nameservers, reads DS at the parent, compares the digests, and reports whether the domain is fine, still within a normal waiting window, or genuinely stuck. Because adding or removing a DS record is a registry level action taken through the registrar's portal or EPP, not a record inside a Cloudflare managed zone, this stays a diagnostic only script. It cannot fix the problem through the Cloudflare DNS API, only tell you clearly when the registrar needs to act.
View this code on GitHub Full runnable folder with tests in the dns-fixes repo.
"""Detect DNSSEC stuck on pending after a domain transfer-in.
Diagnostic only: adding or removing a DS record is a registry level action
taken through the registrar's portal or EPP, not something the Cloudflare
DNS API can touch, so this script never writes anything.
Environment:
DNS_DOMAIN domain to check (default: example.com)
CLOUDFLARE_API_TOKEN accepted for consistency with the other fixes
in this repo, unused (see note in run())
CLOUDFLARE_ZONE_ID accepted for consistency with the other fixes
in this repo, unused (see note in run())
DRY_RUN default "true"; this script never writes
regardless of this flag
HOURS_SINCE_TRANSFER how long ago the transfer completed (default: 72)
PENDING_THRESHOLD_HOURS how long to wait before flagging as stuck (default: 48)
"""
import os
import logging
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("check_ds_pending")
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"
HOURS_SINCE_TRANSFER = float(os.environ.get("HOURS_SINCE_TRANSFER", "72"))
PENDING_THRESHOLD_HOURS = float(os.environ.get("PENDING_THRESHOLD_HOURS", "48"))
def ds_state(cds_digest, cdnskey_present, parent_ds_digests, hours_since_transfer,
pending_threshold_hours=48.0):
"""Pure decision logic, no I/O.
cds_digest: digest string parsed from the child's CDS record (or None if absent)
cdnskey_present: whether a CDNSKEY record is published at the child
parent_ds_digests: list of digest strings currently published as DS at the parent/registry
hours_since_transfer: elapsed time since the transfer-in completed
pending_threshold_hours: how long to wait before flagging as stuck (registrars
typically poll every 24-48h)
Returns one of: "ok" (DS matches child's signal), "not_signed" (no CDS/CDNSKEY,
nothing to publish), "pending_ok" (mismatch but still within normal propagation
window), "stuck_pending" (mismatch beyond threshold, registrar action needed),
"orphaned_ds" (DS exists at parent but child has no CDS/CDNSKEY at all)
"""
if not cds_digest and not cdnskey_present:
return "orphaned_ds" if parent_ds_digests else "not_signed"
if cds_digest and cds_digest in parent_ds_digests:
return "ok"
if hours_since_transfer < pending_threshold_hours:
return "pending_ok"
return "stuck_pending"
def get_child_signals(domain):
"""dnspython, read-only. Returns (cds_digest, cdnskey_present)."""
import dns.resolver
cds_digest = None
try:
answer = dns.resolver.resolve(domain, "CDS")
cds_digest = str(answer[0]).split()[-1].lower()
except Exception:
pass
cdnskey_present = False
try:
dns.resolver.resolve(domain, "CDNSKEY")
cdnskey_present = True
except Exception:
pass
return cds_digest, cdnskey_present
def get_parent_ds_digests(domain):
"""dnspython, read-only. Returns the list of digest strings published as
DS at a normal recursive resolver.
"""
import dns.resolver
digests = []
try:
answer = dns.resolver.resolve(domain, "DS")
for rdata in answer:
digests.append(str(rdata).split()[-1].lower())
except Exception:
pass
return digests
def run():
log.info("Checking DNSSEC delegation for %s (DRY_RUN=%s)", DNS_DOMAIN, DRY_RUN)
cds_digest, cdnskey_present = get_child_signals(DNS_DOMAIN)
parent_ds_digests = get_parent_ds_digests(DNS_DOMAIN)
log.info("Child CDS digest: %s", cds_digest)
log.info("Child CDNSKEY present: %s", cdnskey_present)
log.info("Parent DS digests: %s", parent_ds_digests)
state = ds_state(cds_digest, cdnskey_present, parent_ds_digests,
HOURS_SINCE_TRANSFER, PENDING_THRESHOLD_HOURS)
if state == "ok":
log.info("OK: the parent DS record matches the zone's current key. Nothing to do.")
elif state == "not_signed":
log.info("NOT SIGNED: the zone publishes no CDS/CDNSKEY and the parent has no DS. Nothing to reconcile yet.")
elif state == "pending_ok":
log.info(
"PENDING (normal): the DS does not match yet, but only %.1f hour(s) have passed "
"since the transfer. Registrars typically poll every 24-48 hours. Check again later.",
HOURS_SINCE_TRANSFER,
)
elif state == "orphaned_ds":
log.warning(
"ORPHANED DS: the registry still has a DS record but the zone publishes no "
"CDS/CDNSKEY at all. Contact the registrar to remove the orphaned DS record."
)
else:
log.warning(
"STUCK PENDING: %.1f hour(s) have passed since the transfer and the parent DS "
"still does not match the zone's CDS. This is a registrar-side fix, not something "
"the Cloudflare DNS API can change. Add or correct the DS record in the new "
"registrar's DNSSEC dashboard using the CDS/CDNSKEY values shown at the DNS host.",
HOURS_SINCE_TRANSFER,
)
# Note for future readers: CLOUDFLARE_API_TOKEN and CLOUDFLARE_ZONE_ID are
# accepted for consistency with the other fixes in this repo, and would be
# used to manage records inside a zone already delegated to Cloudflare via
# https://api.cloudflare.com/client/v4/zones/{zone_id}/dns_records, but that
# endpoint only manages zone records like A/CNAME/TXT, not registry level DS
# delegation, so this script never calls it.
if not DRY_RUN:
log.info("DRY_RUN is false, but this check never writes. Fix the DS record at the registrar by hand.")
if __name__ == "__main__":
run()
/**
* Detect DNSSEC stuck on pending after a domain transfer-in.
* Diagnostic only: adding or removing a DS record is a registry level action
* taken through the registrar's portal or EPP, not something the Cloudflare
* DNS API can touch, so this script never writes anything.
*
* Environment:
* DNS_DOMAIN domain to check (default: example.com)
* CLOUDFLARE_API_TOKEN accepted for consistency with the other fixes
* in this repo, unused (see note in run())
* CLOUDFLARE_ZONE_ID accepted for consistency with the other fixes
* in this repo, unused (see note in run())
* DRY_RUN default "true"; this script never writes
* regardless of this flag
* HOURS_SINCE_TRANSFER how long ago the transfer completed (default: 72)
* PENDING_THRESHOLD_HOURS how long to wait before flagging as stuck (default: 48)
*/
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 HOURS_SINCE_TRANSFER = Number(process.env.HOURS_SINCE_TRANSFER || 72);
const PENDING_THRESHOLD_HOURS = Number(process.env.PENDING_THRESHOLD_HOURS || 48);
/**
* Pure decision logic, no I/O.
*
* cdsDigest: digest string parsed from the child's CDS record (or null if absent)
* cdnskeyPresent: whether a CDNSKEY record is published at the child
* parentDsDigests: array of digest strings currently published as DS at the parent/registry
* hoursSinceTransfer: elapsed time since the transfer-in completed
* pendingThresholdHours: how long to wait before flagging as stuck (registrars
* typically poll every 24-48h)
*
* Returns one of: "ok" (DS matches child's signal), "not_signed" (no CDS/CDNSKEY,
* nothing to publish), "pending_ok" (mismatch but still within normal propagation
* window), "stuck_pending" (mismatch beyond threshold, registrar action needed),
* "orphaned_ds" (DS exists at parent but child has no CDS/CDNSKEY at all)
*/
export function dsState(cdsDigest, cdnskeyPresent, parentDsDigests, hoursSinceTransfer,
pendingThresholdHours = 48.0) {
if (!cdsDigest && !cdnskeyPresent) {
return parentDsDigests.length ? "orphaned_ds" : "not_signed";
}
if (cdsDigest && parentDsDigests.includes(cdsDigest)) {
return "ok";
}
if (hoursSinceTransfer < pendingThresholdHours) {
return "pending_ok";
}
return "stuck_pending";
}
async function getChildSignals(domain) {
// The built-in dns module, read-only.
const dns = await import("node:dns/promises");
const resolver = new dns.Resolver();
let cdsDigest = null;
try {
const records = await resolver.resolve(domain, "CDS");
const parts = String(records[0]).trim().split(/\s+/);
cdsDigest = parts[parts.length - 1].toLowerCase();
} catch {
// no CDS published, leave as null
}
let cdnskeyPresent = false;
try {
await resolver.resolve(domain, "CDNSKEY");
cdnskeyPresent = true;
} catch {
// no CDNSKEY published
}
return { cdsDigest, cdnskeyPresent };
}
async function getParentDsDigests(domain) {
// The built-in dns module, read-only.
const dns = await import("node:dns/promises");
const resolver = new dns.Resolver();
try {
const records = await resolver.resolve(domain, "DS");
return records.map((r) => String(r).trim().split(/\s+/).pop().toLowerCase());
} catch {
return [];
}
}
export async function run() {
console.log(`Checking DNSSEC delegation for ${DNS_DOMAIN} (DRY_RUN=${DRY_RUN})`);
const { cdsDigest, cdnskeyPresent } = await getChildSignals(DNS_DOMAIN);
const parentDsDigests = await getParentDsDigests(DNS_DOMAIN);
console.log(`Child CDS digest: ${cdsDigest}`);
console.log(`Child CDNSKEY present: ${cdnskeyPresent}`);
console.log(`Parent DS digests: ${JSON.stringify(parentDsDigests)}`);
const state = dsState(cdsDigest, cdnskeyPresent, parentDsDigests,
HOURS_SINCE_TRANSFER, PENDING_THRESHOLD_HOURS);
if (state === "ok") {
console.log("OK: the parent DS record matches the zone's current key. Nothing to do.");
} else if (state === "not_signed") {
console.log("NOT SIGNED: the zone publishes no CDS/CDNSKEY and the parent has no DS. Nothing to reconcile yet.");
} else if (state === "pending_ok") {
console.log(
`PENDING (normal): the DS does not match yet, but only ${HOURS_SINCE_TRANSFER} hour(s) have ` +
"passed since the transfer. Registrars typically poll every 24-48 hours. Check again later."
);
} else if (state === "orphaned_ds") {
console.warn(
"ORPHANED DS: the registry still has a DS record but the zone publishes no " +
"CDS/CDNSKEY at all. Contact the registrar to remove the orphaned DS record."
);
} else {
console.warn(
`STUCK PENDING: ${HOURS_SINCE_TRANSFER} hour(s) have passed since the transfer and the ` +
"parent DS still does not match the zone's CDS. This is a registrar-side fix, not " +
"something the Cloudflare DNS API can change. Add or correct the DS record in the new " +
"registrar's DNSSEC dashboard using the CDS/CDNSKEY values shown at the DNS host."
);
}
// Note for future readers: CLOUDFLARE_API_TOKEN and CLOUDFLARE_ZONE_ID are
// accepted for consistency with the other fixes in this repo, and would be
// used to manage records inside a zone already delegated to Cloudflare via
// https://api.cloudflare.com/client/v4/zones/{zone_id}/dns_records, but that
// endpoint only manages zone records like A/CNAME/TXT, not registry level DS
// delegation, so this script never calls it.
if (!DRY_RUN) {
console.log("DRY_RUN is false, but this check never writes. Fix the DS record at the registrar by hand.");
}
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
run().catch((e) => { console.error(e); process.exit(1); });
}
Add a test
The state decision is the part worth testing, because it decides whether you treat a pending status as normal or as something needing a registrar ticket. Since it takes plain values in and returns a plain string out, the test needs no network and no real domain.
from check_ds_pending import ds_state
def test_ok_when_digest_matches():
assert ds_state("abc123", True, ["abc123", "def456"], 100.0) == "ok"
def test_not_signed_when_nothing_published_anywhere():
assert ds_state(None, False, [], 100.0) == "not_signed"
def test_pending_ok_within_threshold():
assert ds_state("abc123", True, ["def456"], 10.0) == "pending_ok"
def test_stuck_pending_beyond_threshold():
assert ds_state("abc123", True, ["def456"], 72.0) == "stuck_pending"
def test_stuck_pending_when_parent_has_no_ds_at_all():
assert ds_state("abc123", True, [], 72.0) == "stuck_pending"
def test_orphaned_ds_when_parent_has_ds_but_child_has_nothing():
assert ds_state(None, False, ["abc123"], 72.0) == "orphaned_ds"
def test_custom_threshold_is_respected():
assert ds_state("abc123", True, ["def456"], 30.0, pending_threshold_hours=24.0) == "stuck_pending"
assert ds_state("abc123", True, ["def456"], 30.0, pending_threshold_hours=48.0) == "pending_ok"
import { test } from "node:test";
import assert from "node:assert/strict";
import { dsState } from "./check-ds-pending.js";
test("ok when digest matches", () => {
assert.equal(dsState("abc123", true, ["abc123", "def456"], 100.0), "ok");
});
test("not signed when nothing published anywhere", () => {
assert.equal(dsState(null, false, [], 100.0), "not_signed");
});
test("pending ok within threshold", () => {
assert.equal(dsState("abc123", true, ["def456"], 10.0), "pending_ok");
});
test("stuck pending beyond threshold", () => {
assert.equal(dsState("abc123", true, ["def456"], 72.0), "stuck_pending");
});
test("stuck pending when parent has no DS at all", () => {
assert.equal(dsState("abc123", true, [], 72.0), "stuck_pending");
});
test("orphaned DS when parent has DS but child has nothing", () => {
assert.equal(dsState(null, false, ["abc123"], 72.0), "orphaned_ds");
});
test("custom threshold is respected", () => {
assert.equal(dsState("abc123", true, ["def456"], 30.0, 24.0), "stuck_pending");
assert.equal(dsState("abc123", true, ["def456"], 30.0, 48.0), "pending_ok");
});
Case studies
The stale DS record nobody removed
A site moved registrars to consolidate a handful of domains onto one account. The DNS host never changed and stayed signed the whole time, but the losing registrar's DS record was never cleaned up at the registry. The new registrar's dashboard sat on "pending" for three weeks because it saw an old DS record it had not submitted and would not overwrite it automatically.
A quick DS lookup against the registry showed a digest that matched neither the old nor the current key. A support ticket to remove the orphaned DS record, followed by adding the correct one, cleared it within a day.
The rotation that happened mid-transfer
A team rotated their DNSSEC signing key the same week they transferred a domain, to "get everything done at once." The new registrar's automatic scanner picked up the old CDS record before the rotation finished, then never rechecked, so the DS at the registry pointed at a key that no longer existed.
Comparing the CDS digest at the DNS host against the DS digest at the registry showed the mismatch immediately. Manually pasting the new DS values into the registrar's dashboard fixed it without waiting for another scan cycle.
Once the registry's DS record matches the key your zone is actually signing with, the chain of trust closes end to end. Resolvers show the ad flag, delv reports a clean validation, and the registrar's dashboard stops saying pending on its own within a refresh or two. Keep the checker handy for the next transfer, since this is a one time DS handoff problem, not something that comes back once it is fixed.
FAQ
Why does DNSSEC stay on pending after I transfer my domain to a new registrar?
DNSSEC pending means the new registrar has not yet published a DS record at the registry that matches the key your DNS host is signing with. The DS record is a registry level object that only the registrar can submit, so if its automatic scanner has not run yet, or the DNS host is not publishing the CDS and CDNSKEY records the registrar expects, the DS record never appears and the status stays pending.
Is this a problem with my DNS zone or my registrar?
It is a registrar and registry problem, not a DNS zone problem. Your zone can be fully signed and publishing correct DNSKEY, CDS, and CDNSKEY records while the DS record at the parent zone is still missing or stale, because adding that DS record is an action only the registrar can take.
How long should I wait before treating it as stuck?
Most registrars poll for CDS and CDNSKEY records every 24 to 48 hours. If it has been longer than that since the transfer completed and the DS record still does not match what your DNS host is publishing, treat it as stuck and add or fix the DS record by hand in the registrar's dashboard.
Related field notes
Citations
On the problem:
- Cloudflare Community: Registrar Registration Transfer In, DNSSEC stuck at pending. community.cloudflare.com
- RFC 7344: Automating DNSSEC Delegation Trust Maintenance. datatracker.ietf.org/doc/html/rfc7344
- RFC 8078: Managing DS Records from the Parent via CDS/CDNSKEY. rfc-editor.org/rfc/rfc8078.html
On the solution:
- Cloudflare DNS docs: DNSSEC. developers.cloudflare.com/dns/dnssec
- Cloudflare DNS docs: Troubleshooting DNSSEC. developers.cloudflare.com/dns/dnssec/troubleshooting
- Cloudflare Registrar docs: Transfer your domain to Cloudflare. developers.cloudflare.com/registrar
Stuck on a tricky one?
If you have a DNSSEC, registrar transfer, or delegation problem you would rather hand off, this is the kind of work I do. Message me and we can work through it together.
Did this clear your pending DNSSEC?
If this saved you a confusing week of waiting on a registrar dashboard, 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