Troubleshooting CAA and DNS-01 Validation Failures
After working through this guide you will be able to take a failed certificate order, classify it into exactly one of five causes from the error body alone, reproduce what the certificate authority saw with dig, and prove the fix works before you spend another issuance attempt.
Certificate issuance fails in a frustrating way: the client reports one line, the real evidence lives in a resolver you do not operate, and every blind retry pushes you closer to a rate limit that locks you out for an hour or a week. The discipline that fixes this is narrow. Read the error, classify it, run only the commands for that branch, and confirm the repair against something other than the resolver that has been lying to you all along.
Key diagnostic objectives:
- Extract the ACME problem document from the client log and map its
typefield to one of five root causes. - Reproduce the authority’s own lookup: an authoritative-only query, a full delegation walk, and a CAA climb up the label tree.
- Separate “never written”, “written but invisible”, “forbidden by policy”, “resolver returns SERVFAIL”, and “quota exhausted” without guessing.
- Validate the repair against the staging environment and multiple vantage points before re-running the real order.
Start with the problem document, not the client output
Every ACME failure carries a machine-readable problem document with a type, a detail, and — for DNS failures — the exact name the authority queried. Clients print an abbreviated version and bury the full object in their log. Pull the raw object first:
grep -B2 -A6 'urn:ietf:params:acme:error' /var/log/letsencrypt/letsencrypt.log | tail -40
journalctl -u acme-renew.service --since '2 hours ago' | grep -i 'error\|detail'
The type field alone decides your branch. Everything else in the log is commentary.
| Error type | Typical detail string | What it actually means | Branch |
|---|---|---|---|
dns |
NXDOMAIN looking up TXT |
The name resolved, no TXT existed | A |
dns |
No TXT record found at ... |
The name resolved, other values present | B |
dns |
SERVFAIL looking up TXT |
A resolver refused to answer at all | D |
caa |
CAA record for ... prevents issuance |
Policy in the zone forbids this CA | C |
unauthorized |
Incorrect TXT record ... found |
A value is published but hashes wrong | B |
rateLimited |
too many certificates already issued |
Quota exhausted for this name or account | E |
connection |
query timed out looking up TXT |
Authoritative servers unreachable | B |
Note the split in the dns row: NXDOMAIN and “no TXT found” are different diseases. NXDOMAIN means the name does not exist, which usually points at a missing delegation or a client that wrote into the wrong zone. “No TXT found” means the name exists and answers, but the value the authority wanted was not among the answers.
Prerequisites for a clean diagnosis
You need dig from BIND 9.16 or later (for +yaml and reliable +trace), delv for chain-aware lookups, openssl and jq. You also need a query path that does not go through your corporate resolver — a shell on a cloud instance, or at minimum a public resolver address you can target explicitly. Half of the failures in Branch B are invisible from inside the network that produced them.
dig -v
delv -v
jq --version
One rule before you start: do not re-run the order. Failed validations count against a per-account, per-hostname limit that is far tighter than the certificate limit, and each blind retry both burns quota and overwrites the log evidence you are about to read.
Branch A — the record was never published
The signature is NXDOMAIN, and the fastest confirmation is to ask the zone’s own servers rather than a cache. Query each authoritative name server directly, with recursion disabled so you know the answer came from the zone and not from somewhere else:
ZONE=example.com
NAME=_acme-challenge.example.com
for ns in $(dig +short NS "$ZONE"); do
printf '%-28s ' "$ns"
dig +norecurse +short TXT "$NAME" "@${ns}"
echo
done
An empty column for every server means the write never landed. Three causes account for almost all of it. The plugin authenticated against the wrong zone, which shows up as a 403 or a “zone not found” line in the client log a few seconds before the ACME error. The cleanup hook ran before validation instead of after, which shows as a create immediately followed by a delete. Or the client wrote to a provider that is no longer authoritative for the zone — check the delegation matches what you think it is:
dig +short NS example.com @a.gtld-servers.net
dig +short NS example.com
When the parent delegation and the zone’s own NS set disagree, the client may be writing to a provider nobody queries. That mismatch is the same class of problem covered in debugging DNS propagation delays across global resolvers, and it resolves the same way.
Branch B — published, but not visible to the authority
Here your own dig returns the value and the authority still fails. Four sub-causes, in the order they are worth testing.
A subset of the authoritative servers is stale. The loop from Branch A settles this in one run: if four servers answer and the fifth returns nothing, the authority hit the fifth. Providers with a slow secondary or a zone transfer backlog produce exactly this.
The CNAME chain breaks partway. When the challenge name is delegated into another zone, both hops must resolve. Follow the chain explicitly rather than trusting the final answer:
dig +noall +answer _acme-challenge.example.com CNAME
dig +noall +answer example-com.acme.example.net TXT
dig +trace TXT _acme-challenge.example.com | tail -25
The +trace output is the closest thing to watching the authority work: it starts at the root, follows each delegation, and shows the name servers that actually answered. If the trace ends at a referral instead of an answer, the delegation for the target zone is missing or misspelled.
A stale value is still present. unauthorized: Incorrect TXT record means values exist but none hash correctly. Count them:
dig +short TXT _acme-challenge.example.com | wc -l
For a single-name order, anything above one is residue from an interrupted run. For an order covering both an apex and its wildcard, two is correct — that pairing is explained in the guide on automating ACME DNS-01 challenges for wildcard certificates.
Split-horizon resolution. Your resolver holds an internal view of the zone and answers from it, while the public delegation carries something else entirely. Ask a resolver outside your network and compare:
dig +short TXT _acme-challenge.example.com @9.9.9.9
dig +short TXT _acme-challenge.example.com @208.67.222.222
One agreeing resolver proves nothing
Let’s Encrypt validates from the primary data center plus several remote perspectives on distinct networks, and requires them to corroborate before the challenge is marked valid. A record that has reached one anycast region and not another will satisfy your dig and fail the order. This is deliberate — it is what makes an on-path attacker unable to forge a single view of DNS — and it means your verification has to be plural too.
Reproduce that plurality yourself by querying from more than one network before you re-run an order — a shell on an instance in another region, plus a public resolver on a different backbone, is enough to catch a partially propagated record.
Branch C — CAA policy forbids this authority
A caa error is a policy decision, not a resolution problem. The authority walks up the label tree from the requested name toward the apex, stopping at the first label that has a CAA record set, and evaluates only that set. Labels above it are ignored entirely. Reproduce the walk one label at a time:
for name in www.api.example.com api.example.com example.com; do
printf '%-24s ' "$name"
dig +short CAA "$name" | tr '\n' ' '
echo
done
www.api.example.com
api.example.com
example.com 0 issue "letsencrypt.org" 0 iodef "mailto:[email protected]"
Three specifics catch people out. A wildcard order consults issuewild before issue, so a zone with issue "letsencrypt.org" and issuewild ";" will happily issue a single-name certificate and refuse the wildcard. A subdomain that carries any CAA set completely overrides the apex, which is how a forgotten record on api.example.com blocks a CA the apex explicitly allows. And the accounturi and validationmethods parameters, if present, restrict issuance further — a matching CA name is not sufficient when the record also pins an account:
dig +noall +answer CAA example.com
example.com. 3600 IN CAA 0 issue "letsencrypt.org; validationmethods=dns-01"
example.com. 3600 IN CAA 128 issuewild ";"
The 128 flag is the critical bit: a CA that does not understand the property must refuse issuance rather than ignore the record. Getting the syntax and the flags right is covered in depth in the guide on publishing CAA records to restrict certificate issuance.
Branch D — a broken signing chain returns SERVFAIL
A validating resolver that cannot verify a signature returns SERVFAIL rather than the data, and the authority reports a DNS problem that looks nothing like a signing failure. The definitive test is one query pair: +cd (checking disabled) tells the resolver to hand back unverified data. If the query succeeds with +cd and fails without it, the chain is broken.
dig TXT _acme-challenge.example.com @1.1.1.1 +cd +short # returns data
dig TXT _acme-challenge.example.com @1.1.1.1 +short # empty, status SERVFAIL
dig TXT _acme-challenge.example.com @1.1.1.1 | grep -E 'status:'
;; ->>HEADER<<- opcode: QUERY, status: SERVFAIL, id: 41207
Once the pattern is confirmed, delv names the record that fails to verify:
delv @1.1.1.1 TXT _acme-challenge.example.com +rtrace
The most common trigger during certificate work is a delegation zone that is signed while its DS record at the parent is missing or stale, so every name under it is bogus rather than merely unsigned. Compare the DS at the parent with the DNSKEY at the child:
dig +short DS acme.example.net
dig +short DNSKEY acme.example.net | head -2
A mismatch here is a rollover that did not complete. The repair path — and how to keep it from recurring — is in debugging DNSSEC validation failures and, for the automation side, automating DNSSEC key rollover.
Branch E — you are rate limited
rateLimited is not a DNS problem and no dig command will help. Read the detail string and the Retry-After header, then stop. Let’s Encrypt enforces several independent counters, and the one that bites during a debugging session is the failed-validation limit — five failures per account, per hostname, per hour. That is a budget you exhaust in ten minutes of blind retrying.
| Limit | Ceiling | Window | Reset behavior |
|---|---|---|---|
| Certificates per registered domain | 50 | 7 days | Rolling |
| Duplicate certificate (same name set) | 5 | 7 days | Rolling |
| Failed validations | 5 | 1 hour | Rolling, per hostname |
| New orders per account | 300 | 3 hours | Rolling |
| Accounts per IP address | 10 | 3 hours | Rolling |
The escape hatch is the staging environment, which has limits roughly thirty times higher and issues a certificate from an untrusted root. Every subsequent debugging attempt should run there.
Verification — prove the fix before spending an attempt
Never confirm a repair with the same command that misled you. Use three independent proofs.
First, ask every authoritative server, with recursion off, and require them all to agree:
NAME=_acme-challenge.example.com
for ns in $(dig +short NS example.com); do
printf '%-30s %s\n' "$ns" "$(dig +norecurse +short TXT "$NAME" "@${ns}" | tr '\n' ' ')"
done
Second, re-run the exact policy check the authority performs, including the wildcard property:
dig +short CAA example.com | grep -E 'issue|issuewild'
Third, drive a complete order against staging, which exercises the plugin, the delegation, the propagation wait, and the CAA evaluation without touching production quota:
certbot certonly --dns-cloudflare \
--dns-cloudflare-credentials /etc/letsencrypt/cloudflare.ini \
--server https://acme-staging-v02.api.letsencrypt.org/directory \
--dry-run -d example.com -d '*.example.com'
Congratulations, all simulated renewals succeeded:
/etc/letsencrypt/live/example.com/fullchain.pem (success)
Only after all three pass should you re-run the real order.
Troubleshooting
The dashboard shows the record but resolvers do not
Provider consoles display the intended state of a zone, not the served state. A record that failed to publish to the edge, or a zone whose changes are queued behind a pending version, looks identical to a healthy one in the UI. Trust dig against the authoritative servers exclusively, and if the two disagree, look for a zone version that was never activated or an API write that returned a soft error.
The wildcard failed but the apex succeeded
This asymmetry is almost always CAA, because issuewild and issue are evaluated separately. It can also be a plugin that overwrites the TXT record set rather than appending, so the second authorization destroys the first. Distinguish them in one step: if the error type is caa, it is policy; if it is dns or unauthorized, it is the record set.
The account key changed between the order and the answer
If a host was rebuilt, restored from a different backup, or is running a second client alongside the first, the key used to derive the published value no longer matches the account that placed the order. The symptom is unauthorized: Incorrect TXT record with a value that is clearly present and well-formed. There is no repair for the outstanding order — abandon it, confirm which account directory the client is really using, and start a new one.
certbot certificates
ls -l /etc/letsencrypt/accounts/*/directory/*/
A negative answer is still cached from before the write
If anything queried the challenge name before the record existed, the NXDOMAIN answer is held for the interval published in the zone’s SOA, and no amount of correct data at the authoritative servers will shorten it. The tell is a challenge that fails immediately after a write and then succeeds on a retry minutes later. Lower the delegation zone’s SOA minimum, as described in the guide on negative caching and the SOA minimum TTL.
Frequently Asked Questions
Why does the authority see a different answer than my dig?
Because it queries from several networks and validates DNSSEC, while your workstation usually queries one cache on one path. A record that has reached the region nearest you but not another region, or a signing chain that only your non-validating resolver tolerates, produces exactly this split.
Does a CAA record on a subdomain override the apex?
Yes, and completely. The climb stops at the first label carrying any CAA set, so a stray record on api.example.com replaces the apex policy for every name at or below it. Nothing above that label is consulted.
What is the fastest way to tell DNSSEC from a missing record?
Run the query twice against the same resolver, once with +cd and once without. Data with +cd and SERVFAIL without means the chain is broken; empty in both cases means the record genuinely is not there.
How long am I locked out after too many failures? The failed-validation counter is a rolling hour, so it clears an hour after the fifth failure rather than at a fixed boundary. The certificate limits use rolling seven-day and three-hour windows. Move to the staging environment instead of waiting.
Can I test a CAA change without ordering a certificate?
Yes. Query the effective set at every label between the requested name and the apex, and confirm the first set you find contains the correct issue and issuewild values for your authority. That is the entire evaluation the authority performs, so reproducing it locally is sufficient.