Diagnostic TLS Certificates / CAA
CAA lookup fails due to broken DNSSEC chain
A certificate renewal fails with a vague DNS problem looking up CAA error, but your CAA record is fine when you check it yourself. The real problem is one step earlier: your domain has DNSSEC turned on, and the chain of trust behind it is broken, so a validating resolver cannot confirm any answer at all, CAA included. Here is how to prove that, why it happens, and how to fix the chain instead of chasing a CAA record that was never wrong.
Under the CA/Browser Forum Baseline Requirements, a certificate authority must validate DNSSEC when it is present, and any DNSSEC validation error, which shows up as SERVFAIL, must be treated as an inability to confirm CAA policy. So the CA refuses to issue or renew, even though your actual CAA record may be perfectly fine. The usual cause is a DS record at the registrar that no longer matches the DNSKEY signing the zone at your DNS host, often left over from a half finished key rollover or a DNS migration. Check with dig @1.1.1.1 CAA example.com: SERVFAIL there, but NOERROR with dig @1.1.1.1 +cd CAA example.com, confirms DNSSEC is the cause. The fix is to make the DS record match the live DNSKEY, or to remove DNSSEC cleanly in the right order. Full commands and a script are below.
The problem in plain words
DNSSEC adds a signature to DNS answers so a resolver can prove the answer was not tampered with along the way. That proof depends on a chain: the registrar publishes a DS record, and that DS record must match a DNSKEY that your DNS host is actively using to sign the zone. If those two pieces stop matching, a validating resolver cannot build a complete, trusted chain from the root down to your domain.
When that happens, the resolver does not just ignore DNSSEC and answer anyway. It refuses to answer at all, for any record type, and returns SERVFAIL. That includes the CAA record a certificate authority checks before it issues or renews. The CA sees a broken lookup, not a real answer, and per the rules it must treat that as if it could not confirm CAA policy. It has to refuse, even though nothing about your CAA record itself is wrong.
Why it happens
- An incomplete key rollover: a new DNSKEY was published and used to sign the zone at the DNS host, but the matching new DS record was never pushed to the registrar, so the registrar still has the old, now unused, digest.
- A DNS provider migration: the zone moved to a new host with new signing keys, but the DS record at the registrar, which lives outside the DNS host entirely, was never updated to match.
- DNSSEC was turned off at the DNS host without removing the DS record at the registrar first, so the registrar keeps publishing a DS record that now points at nothing.
- An expired RRSIG: the signature over the DNSKEY or CAA record set passed its validity window and was never resigned, so even a correct DS-to-DNSKEY match cannot produce a currently valid signature.
Per the CA/Browser Forum's Ballot SC-085v2, this is not a gray area for the CA. When DNSSEC is present on a domain, the CA must validate it as part of the CAA and domain control checks, and a validation error must be treated as an inability to confirm CAA policy, which means refuse. The CA has no way to tell "the chain is broken" apart from "the record you actually want is blocked," so it always assumes the safer answer.
There is no CAA record to fix here. The CAA record can be completely correct, or even absent, and the renewal still fails, because the resolver never gets far enough to read it. The fix always happens one layer down, in the DS record at the registrar and the DNSKEY at the DNS host, not in the CAA record itself.
The fix, as a flow
Reproduce what the CA sees with a validating resolver, confirm DNSSEC is the cause by comparing a plain query against one with validation disabled, then look at the DS and DNSKEY pieces directly to see which one is stale, and fix that piece at its source, either the registrar or the DNS host.
How to fix it
Reproduce what the CA sees
Query CAA against a DNSSEC-validating resolver, the same kind of resolver a CA uses. A broken chain shows status: SERVFAIL instead of status: NOERROR.
dig @1.1.1.1 CAA example.com
# a bad result shows this near the top of the answer:
# ;; ->>HEADER<<- opcode: QUERY, status: SERVFAIL, id: 12345
Confirm it is DNSSEC, not a dead nameserver
Run the same query with +cd, which tells the resolver to skip DNSSEC validation. If this succeeds while the first query SERVFAILs, DNSSEC is the culprit, not a broken nameserver or a network problem.
dig @1.1.1.1 +cd CAA example.com
# a good result here, with status: NOERROR, while the plain query above
# SERVFAILs, confirms DNSSEC validation is the cause
Check the DS record published at the registrar
The DS record lives at the parent zone, published through your registrar. Query it directly to see the digest the registrar is advertising to the world.
dig DS example.com +short
# example of a live DS record from the registrar:
# 2371 13 2 A1B2C3D4E5F6...(digest continues)
Check the DNSKEY actually signing the zone
Query the DNSKEY at your authoritative nameserver to see the key or keys currently in use, and check the RRSIG validity window in case a signature simply expired rather than a key changing.
dig DNSKEY example.com +short
dig +dnssec DNSKEY example.com
# look at the RRSIG line for this record set and compare its
# validity window (the two timestamps) against the current date
Compare the digest, or use DNSViz
Compare the DS digest from step 3 against a hash of the current DNSKEY from step 4, or use DNSViz, which draws the whole chain and marks the broken link in red.
# open in a browser, replacing example.com with your domain:
# https://dnsviz.net/d/example.com/dnssec/
#
# a bad result shows a red error node on the DS-to-DNSKEY link,
# or a red node flagging an expired RRSIG
Fix it: match the DS to the live DNSKEY, or remove DNSSEC cleanly
There is no CAA record to edit here. Option one, keep DNSSEC: get the current KSK's DS data from the DNS host, delete the stale DS record at the registrar, and add the new one exactly as shown, then wait for TTL propagation. Option two, disable DNSSEC: remove the DS record at the registrar first, wait at least one and a half times the DS TTL, then remove the DNSKEY at the DNS host. Never do both steps at once or in the wrong order, since removing the key before the DS expires is exactly what causes this SERVFAIL.
; Cloudflare shows this under DNS > Settings > DNSSEC on the zone
; paste it into your registrar's DNSSEC / DS records page exactly as shown
example.com. IN DS 2371 13 2 A1B2C3D4E5F6789...(full digest from the host)
; Option 1: replace the stale DS with this current one, then wait for
; TTL propagation, roughly 1 to 48 hours depending on the TLD
; Option 2: to disable DNSSEC safely, delete the DS record above first,
; wait at least 1.5x its TTL (about 36 hours for a 24 hour TTL), and only
; then remove the DNSKEY / turn off signing at the DNS host
How to check it worked
Re-run the CAA query against more than one validating resolver and confirm a real answer comes back, then check DNSViz for a fully green chain, then retry the actual certificate renewal.
dig @1.1.1.1 CAA example.com
dig @8.8.8.8 CAA example.com
# good result: status: NOERROR on both, with the ad flag set if DNSSEC
# is still enabled, and either the CAA records or an empty answer
# then re-open https://dnsviz.net/d/example.com/dnssec/
# good result: a fully green chain from the root down, no red or
# yellow nodes on DS, DNSKEY, or RRSIG
certbot renew
# good result: completes with no "DNS problem: SERVFAIL looking up CAA" error
The full code
Here is a script that queries CAA against a DNSSEC-validating resolver, retries the same query with checking disabled, and uses a pure function to decide whether the failure is a broken DNSSEC chain, an expired signature, or something unrelated to DNSSEC entirely. If you are on Cloudflare and the fix is to disable DNSSEC, it can also read or update the zone's DNSSEC status through the Cloudflare API. Updating the DS record itself always stays a registrar action, outside any DNS provider's record API.
View this code on GitHub Full runnable folder with tests in the dns-fixes repo.
"""Detect a broken DNSSEC chain that turns a CAA lookup into a SERVFAIL and,
optionally, check or change a Cloudflare zone's DNSSEC status. 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("caa_lookup_servfail_dnssec")
def diagnose_caa_dnssec_break(servfail_with_validation, ok_with_cd, ds_matches_dnskey, rrsig_expired):
"""Pure decision function. No DNS I/O, no network calls.
servfail_with_validation: True if the CAA query against a validating
resolver (e.g. dig @1.1.1.1 CAA example.com) returned SERVFAIL.
ok_with_cd: True if the same query with checking disabled
(dig @1.1.1.1 +cd CAA example.com) returned NOERROR.
ds_matches_dnskey: True if the DS digest at the registrar matches a
hash of the DNSKEY currently signing the zone.
rrsig_expired: True if the RRSIG over DNSKEY or CAA has passed its
validity window.
Returns one of "ok", "broken_dnssec_chain_ds_mismatch",
"broken_dnssec_chain_expired_rrsig", "not_dnssec_related".
"""
if not servfail_with_validation:
return "ok"
if not ok_with_cd:
return "not_dnssec_related"
if rrsig_expired:
return "broken_dnssec_chain_expired_rrsig"
if not ds_matches_dnskey:
return "broken_dnssec_chain_ds_mismatch"
return "broken_dnssec_chain_ds_mismatch"
def run():
# Imported lazily so the pure function above can be tested with no
# network libraries installed at all.
import dns.resolver
import dns.flags
import requests
domain = os.environ["DNS_DOMAIN"]
resolver_ip = os.environ.get("VALIDATING_RESOLVER", "1.1.1.1")
dry_run = os.environ.get("DRY_RUN", "true").lower() == "true"
resolver = dns.resolver.Resolver(configure=False)
resolver.nameservers = [resolver_ip]
servfail_with_validation = False
try:
resolver.resolve(domain, "CAA")
except dns.resolver.NoAnswer:
pass
except dns.exception.DNSException as exc:
if "SERVFAIL" in str(exc) or exc.__class__.__name__ == "NoNameservers":
servfail_with_validation = True
cd_resolver = dns.resolver.Resolver(configure=False)
cd_resolver.nameservers = [resolver_ip]
cd_resolver.use_edns(0, dns.flags.DO, 4096)
ok_with_cd = True
try:
query = dns.message.make_query(domain, "CAA", want_dnssec=False)
query.flags |= dns.flags.CD
answer = dns.query.udp(query, resolver_ip, timeout=10)
ok_with_cd = answer.rcode() == 0
except dns.exception.DNSException:
ok_with_cd = False
ds_matches_dnskey = True
rrsig_expired = False
try:
ds_answer = dns.resolver.resolve(domain, "DS")
dnskey_answer = dns.resolver.resolve(domain, "DNSKEY")
digests = {str(r).split()[3] for r in ds_answer}
computed = set()
for key in dnskey_answer:
for algo in (1, 2):
try:
ds = dns.dnssec.make_ds(domain, key, algo)
computed.add(str(ds).split()[3])
except Exception:
continue
ds_matches_dnskey = bool(digests & computed)
except Exception:
ds_matches_dnskey = False
try:
rrsig_answer = dns.resolver.resolve(domain, "DNSKEY", want_dnssec=True)
import time
now = int(time.time())
for rrset in rrsig_answer.response.answer:
for rr in rrset:
if rr.rdtype == dns.rdatatype.RRSIG and rr.expiration < now:
rrsig_expired = True
except Exception:
pass
verdict = diagnose_caa_dnssec_break(
servfail_with_validation, ok_with_cd, ds_matches_dnskey, rrsig_expired
)
log.info("Diagnosis for %s: %s", domain, verdict)
if verdict == "ok":
log.info("CAA resolves fine through a validating resolver. Nothing to do.")
return
if verdict == "not_dnssec_related":
log.warning(
"SERVFAIL does not clear with +cd, so this looks like a dead "
"nameserver or network issue, not a broken DNSSEC chain."
)
return
log.warning("Broken DNSSEC chain detected: %s", verdict)
zone_id = os.environ.get("CLOUDFLARE_ZONE_ID")
api_token = os.environ.get("CLOUDFLARE_API_TOKEN")
if not zone_id or not api_token:
log.info(
"No Cloudflare credentials set. Fix the DS record at the "
"registrar, or disable DNSSEC in the correct order, then re-run."
)
return
headers = {"Authorization": f"Bearer {api_token}", "Content-Type": "application/json"}
resp = requests.get(
f"https://api.cloudflare.com/client/v4/zones/{zone_id}/dnssec",
headers=headers, timeout=30,
)
resp.raise_for_status()
status = resp.json()["result"]["status"]
log.info("Cloudflare zone DNSSEC status is currently: %s", status)
if dry_run:
log.info("Dry run: would not change DNSSEC status automatically.")
return
disable_dnssec = os.environ.get("DISABLE_DNSSEC", "false").lower() == "true"
if disable_dnssec and status == "active":
log.warning(
"DISABLE_DNSSEC=true, but the DS record must already be removed "
"at the registrar and its TTL must have expired before this "
"runs, otherwise this recreates the exact SERVFAIL problem."
)
patch = requests.patch(
f"https://api.cloudflare.com/client/v4/zones/{zone_id}/dnssec",
headers=headers, json={"status": "disabled"}, timeout=30,
)
patch.raise_for_status()
log.info("Requested Cloudflare to disable DNSSEC for this zone.")
else:
log.info(
"Updating the DS record itself is a registrar action outside "
"the Cloudflare DNS records API. Get the current DS data from "
"DNS > Settings > DNSSEC in the Cloudflare dashboard and paste "
"it into the registrar's DS records page."
)
if __name__ == "__main__":
run()
/**
* Detect a broken DNSSEC chain that turns a CAA lookup into a SERVFAIL and,
* optionally, check or change a Cloudflare zone's DNSSEC status. Safe to
* run on a schedule. Stays in dry run until DRY_RUN=false.
*/
import { pathToFileURL } from "node:url";
export function diagnoseCaaDnssecBreak(servfailWithValidation, okWithCd, dsMatchesDnskey, rrsigExpired) {
// Pure decision function. No DNS I/O, no network calls.
//
// servfailWithValidation: true if the CAA query against a validating
// resolver (e.g. dig @1.1.1.1 CAA example.com) returned SERVFAIL.
// okWithCd: true if the same query with checking disabled
// (dig @1.1.1.1 +cd CAA example.com) returned NOERROR.
// dsMatchesDnskey: true if the DS digest at the registrar matches a
// hash of the DNSKEY currently signing the zone.
// rrsigExpired: true if the RRSIG over DNSKEY or CAA has passed its
// validity window.
//
// Returns one of "ok", "broken_dnssec_chain_ds_mismatch",
// "broken_dnssec_chain_expired_rrsig", "not_dnssec_related".
if (!servfailWithValidation) return "ok";
if (!okWithCd) return "not_dnssec_related";
if (rrsigExpired) return "broken_dnssec_chain_expired_rrsig";
return "broken_dnssec_chain_ds_mismatch";
}
async function queryCaaServfail(domain, resolverIp) {
const dns = await import("node:dns");
const resolver = new dns.promises.Resolver();
resolver.setServers([resolverIp]);
try {
await resolver.resolveAny(domain);
return false;
} catch (err) {
return err.code === "ESERVFAIL" || err.code === "SERVFAIL";
}
}
export async function run() {
// Imported lazily so the pure function above can be tested with no
// network modules touched at all.
const { execFile } = await import("node:child_process");
const { promisify } = await import("node:util");
const execFileAsync = promisify(execFile);
const domain = process.env.DNS_DOMAIN;
const resolverIp = process.env.VALIDATING_RESOLVER || "1.1.1.1";
const dryRun = (process.env.DRY_RUN || "true").toLowerCase() === "true";
async function digStatus(args) {
try {
const { stdout } = await execFileAsync("dig", args);
const match = stdout.match(/status:\s*(\w+)/);
return match ? match[1] : "UNKNOWN";
} catch {
return "UNKNOWN";
}
}
const plainStatus = await digStatus(["@" + resolverIp, "CAA", domain]);
const servfailWithValidation = plainStatus === "SERVFAIL";
const cdStatus = await digStatus(["@" + resolverIp, "+cd", "CAA", domain]);
const okWithCd = cdStatus === "NOERROR";
const dsOut = await digStatus(["DS", domain, "+short"]).catch(() => "");
const dsMatchesDnskey = true; // full digest comparison is done with dnspython in the Python version
const rrsigExpired = false; // RRSIG expiry parsing is done with dnspython in the Python version
const verdict = diagnoseCaaDnssecBreak(servfailWithValidation, okWithCd, dsMatchesDnskey, rrsigExpired);
console.log(`Diagnosis for ${domain}: ${verdict}`);
if (verdict === "ok") {
console.log("CAA resolves fine through a validating resolver. Nothing to do.");
return;
}
if (verdict === "not_dnssec_related") {
console.warn(
"SERVFAIL does not clear with +cd, so this looks like a dead " +
"nameserver or network issue, not a broken DNSSEC chain."
);
return;
}
console.warn(`Broken DNSSEC chain detected: ${verdict}`);
const zoneId = process.env.CLOUDFLARE_ZONE_ID;
const apiToken = process.env.CLOUDFLARE_API_TOKEN;
if (!zoneId || !apiToken) {
console.log(
"No Cloudflare credentials set. Fix the DS record at the registrar, " +
"or disable DNSSEC in the correct order, then re-run."
);
return;
}
const headers = { Authorization: `Bearer ${apiToken}`, "Content-Type": "application/json" };
const res = await fetch(`https://api.cloudflare.com/client/v4/zones/${zoneId}/dnssec`, { headers });
if (!res.ok) throw new Error(`Cloudflare API returned ${res.status}`);
const body = await res.json();
const status = body.result.status;
console.log(`Cloudflare zone DNSSEC status is currently: ${status}`);
if (dryRun) {
console.log("Dry run: would not change DNSSEC status automatically.");
return;
}
const disableDnssec = (process.env.DISABLE_DNSSEC || "false").toLowerCase() === "true";
if (disableDnssec && status === "active") {
console.warn(
"DISABLE_DNSSEC=true, but the DS record must already be removed at " +
"the registrar and its TTL must have expired before this runs, " +
"otherwise this recreates the exact SERVFAIL problem."
);
const patch = await fetch(`https://api.cloudflare.com/client/v4/zones/${zoneId}/dnssec`, {
method: "PATCH",
headers,
body: JSON.stringify({ status: "disabled" }),
});
if (!patch.ok) throw new Error(`Cloudflare API returned ${patch.status}`);
console.log("Requested Cloudflare to disable DNSSEC for this zone.");
} else {
console.log(
"Updating the DS record itself is a registrar action outside the " +
"Cloudflare DNS records API. Get the current DS data from DNS > " +
"Settings > DNSSEC in the Cloudflare dashboard and paste it into " +
"the registrar's DS records page."
);
}
}
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 diagnosis is the part worth testing on its own, because it decides whether the script blames DNSSEC at all. It takes four plain booleans that were already fetched by dig or dnspython elsewhere, and does no DNS work itself, so the test needs no network and no DNS library.
from caa_lookup_servfail_dnssec import diagnose_caa_dnssec_break
def test_ok_when_no_servfail():
result = diagnose_caa_dnssec_break(False, True, True, False)
assert result == "ok"
def test_not_dnssec_related_when_cd_also_fails():
result = diagnose_caa_dnssec_break(True, False, True, False)
assert result == "not_dnssec_related"
def test_ds_mismatch_when_digests_disagree():
result = diagnose_caa_dnssec_break(True, True, False, False)
assert result == "broken_dnssec_chain_ds_mismatch"
def test_expired_rrsig_takes_priority():
result = diagnose_caa_dnssec_break(True, True, True, True)
assert result == "broken_dnssec_chain_expired_rrsig"
def test_expired_rrsig_even_if_ds_also_mismatches():
result = diagnose_caa_dnssec_break(True, True, False, True)
assert result == "broken_dnssec_chain_expired_rrsig"
import { test } from "node:test";
import assert from "node:assert/strict";
import { diagnoseCaaDnssecBreak } from "./caa-lookup-servfail-dnssec.js";
test("ok when no SERVFAIL", () => {
assert.equal(diagnoseCaaDnssecBreak(false, true, true, false), "ok");
});
test("not dnssec related when +cd also fails", () => {
assert.equal(diagnoseCaaDnssecBreak(true, false, true, false), "not_dnssec_related");
});
test("ds mismatch when digests disagree", () => {
assert.equal(diagnoseCaaDnssecBreak(true, true, false, false), "broken_dnssec_chain_ds_mismatch");
});
test("expired rrsig takes priority", () => {
assert.equal(diagnoseCaaDnssecBreak(true, true, true, true), "broken_dnssec_chain_expired_rrsig");
});
test("expired rrsig even if ds also mismatches", () => {
assert.equal(diagnoseCaaDnssecBreak(true, true, false, true), "broken_dnssec_chain_expired_rrsig");
});
Case studies
The rollover that finished on one side only
A team rotated their DNSSEC signing key ahead of a scheduled maintenance window. The DNS host applied the new DNSKEY and started signing with it immediately, but the ticket to update the DS record at the registrar sat untouched over a holiday weekend. A routine certificate renewal three days later failed with a DNS problem looking up CAA error.
Running dig @1.1.1.1 CAA showed SERVFAIL, and the same query with +cd succeeded, pointing straight at DNSSEC. Comparing the DS digest at the registrar against the new DNSKEY confirmed the mismatch. Updating the DS record fixed the chain within a few hours of propagation.
DNSSEC left behind during a DNS host switch
A domain moved its nameservers to a new DNS host as part of a broader infrastructure move. DNSSEC had been enabled at the old host, and the migration checklist covered nameservers, records, and TTLs, but nobody remembered the DS record was a separate, registrar-side setting that also needed attention.
The new host signed the zone with a completely different key, so the old DS record no longer matched anything. DNSViz showed a clear red error at the DS-to-DNSKEY link. Since the team preferred not to manage DNSSEC after the move, they removed the DS record at the registrar, waited out the TTL, then disabled signing at the new host, in that order.
Once the DS record matches the live DNSKEY, or DNSSEC has been removed the safe way, a CAA query against any validating resolver returns a real answer instead of SERVFAIL, and DNSViz shows a fully green chain with no error nodes anywhere. Certificate renewals go back to failing or succeeding based on the actual CAA policy, not on a chain that was never trustworthy in the first place.
FAQ
Why does my certificate renewal fail with a DNS problem looking up CAA error?
Your domain has DNSSEC turned on, but the DS record at the registrar no longer matches the DNSKEY that is actually signing the zone at your DNS host. A validating resolver cannot confirm the CAA record is genuine, so it returns SERVFAIL, and the certificate authority is required to treat that as unable to check CAA policy and refuse to issue.
How do I tell if SERVFAIL is caused by DNSSEC and not a dead nameserver?
Run the same CAA query twice against a validating resolver such as 1.1.1.1, once as normal and once with the +cd flag, which disables DNSSEC validation. If the plain query returns SERVFAIL but the +cd query succeeds, the nameservers are fine and DNSSEC validation is the cause.
What is the safe order of steps when disabling DNSSEC to fix this?
Remove the DS record at the registrar first, then wait at least one and a half times the DS record's TTL for caches to expire it, roughly 36 hours for a 24 hour TTL, and only then remove the DNSKEY and turn off signing at the DNS host. Doing it in the other order leaves a DS record with no matching key, which causes the exact SERVFAIL this article describes.
Related field notes
Citations
On the problem:
- CA/Browser Forum Ballot SC-085v2: Require Validation of DNSSEC (when present) for CAA and DCV Lookups. cabforum.org/2025/06/18/ballot-sc-085v2
- Let's Encrypt docs: Certification Authority Authorization (CAA). letsencrypt.org/docs/caa
- RFC 8659: DNS Certification Authority Authorization (CAA) Resource Record. datatracker.ietf.org/doc/html/rfc8659
On the solution:
- Cloudflare DNS docs: Troubleshooting DNSSEC. developers.cloudflare.com/dns/dnssec/troubleshooting
- Cloudflare Registrar docs: Domain Name System Security Extensions (DNSSEC). developers.cloudflare.com/registrar/get-started/enable-dnssec
- DNSViz: DNSSEC chain of trust visualization tool. dnsviz.net
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.
Did this fix your certificate renewal?
If this saved you a stalled renewal or a scramble before a certificate expired, 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