Diagnostic TLS Certificates / CAA
CAA record blocks certificate issuance for the intended CA
You point Let's Encrypt, DigiCert, or another certificate authority at your domain and issuance fails with a CAA error, even though the domain, DNS, and validation all look fine. A CAA record somewhere in the DNS tree is telling every CA who is allowed to issue for this name, and the CA you are actually using is not on that list. Here is how to prove it, how it usually happens, and how to fix the record so issuance goes through.
A CAA (Certification Authority Authorization) record restricts which certificate authorities may issue certificates for a domain. Per RFC 8659, every CA must check this record before issuing and must refuse if the domain has an issue or issuewild record that does not name it. Check with dig +short CAA example.com. If you see an entry like 0 issue "digicert.com" but you are trying to issue with Let's Encrypt, that is the block. The fix is to add an issue record for the CA you actually use, for example example.com. IN CAA 0 issue "letsencrypt.org". Full commands, records, and a script are below.
The problem in plain words
A CAA record is a short allow-list that lives in DNS, next to your other records like A and MX. It answers one question for any certificate authority that comes along: are you allowed to issue a certificate for this name? If the record says only digicert.com is allowed and Let's Encrypt tries to issue anyway, Let's Encrypt is required to say no. This is not a bug in the CA or in your certificate request. It is the CAA record doing exactly what it was set up to do, just not for the CA you meant.
This trips people up because everything else about the request looks correct. Domain ownership is proven, DNS resolves fine, the ACME challenge or validation file is in place, and the request still gets rejected with a CAA-specific error. The record is often left over from a previous setup, or added automatically by a DNS host without much fanfare.
Why it happens
- The domain migrated from one CA to another, such as from DigiCert to Let's Encrypt, and nobody updated the CAA record to add the new CA's name alongside or instead of the old one.
- A DNS host silently adds its own restrictive CAA record when a zone is created, often naming only one CA, such as
letsencrypt.org, which then blocks any other CA that tries to issue. - Someone locked the CAA record to a specific ACME account with an
accounturiparameter, then later switched ACME clients or created a new account, so the account in the record no longer matches. - The record is set to
0 issue ";", an intentionally empty value meant to block all issuance for a name that should never get a public certificate, and someone forgot it was there when a real certificate was needed. - A subdomain has no CAA record of its own, and CAA is inherited from the parent domain, so a restrictive record set higher up the tree blocks issuance for the subdomain too, even though nobody looked at the subdomain's own DNS.
CAA is checked by the CA, not by you, and it is checked fresh on every issuance attempt. Per RFC 8659, a CA must walk up the DNS tree from the exact name being requested until it finds a CAA record set, and it must refuse to issue if that record set exists and does not name the CA. The record is not a suggestion. Fixing it means adding the CA's exact identifier string to an issue record, not just double checking that DNS otherwise looks fine.
The fix, as a flow
Find the CAA record that actually applies by walking up from the exact name to the apex, compare its CA identifier strings against the CA you are using, then add or edit the record so it names that CA, and re-trigger issuance.
How to fix it
List every CAA record up the DNS tree
CAA is inherited from the parent domain if the exact name you are requesting has none of its own. Check the name you are issuing for first, then check the apex too, so you see the record that actually applies.
dig +short CAA www.example.com
dig +short CAA example.com
# a bad result looks like this if you intend to use Let's Encrypt:
# 0 issue "digicert.com"
Confirm across resolvers to rule out stale cache
Query at least two public resolvers directly. If they disagree, you may be looking at a cached answer rather than the record that is actually live at your DNS host.
dig @1.1.1.1 CAA example.com
dig @8.8.8.8 CAA example.com
Check for an accounturi lock and the live issuer
A CAA record can name the right CA but still block issuance if it locks to a specific ACME account with accounturi and you switched clients or accounts. Also check which CA last issued the live certificate, to confirm what actually changed.
# a value like this rejects any account except the one listed:
# letsencrypt.org;accounturi=https://acme-v02.api.letsencrypt.org/acme/acct/12345678
openssl s_client -connect example.com:443 -servername example.com /dev/null \
| openssl x509 -noout -issuer
Add or edit the CAA record for the intended CA
Set an issue record naming the CA's exact CAA identifier string. Add issuewild too if you need wildcard certificates, since some CAs check that tag separately for wildcard names. If a record reads 0 issue ";", replace it, since that blocks every CA.
; allow Let's Encrypt to issue normal and wildcard certs
example.com. 3600 IN CAA 0 issue "letsencrypt.org"
example.com. 3600 IN CAA 0 issuewild "letsencrypt.org"
; other common CA identifier strings, add as needed
; example.com. 3600 IN CAA 0 issue "digicert.com"
; example.com. 3600 IN CAA 0 issue "sectigo.com"
; example.com. 3600 IN CAA 0 issue "pki.goog"
; example.com. 3600 IN CAA 0 issue "amazon.com"
; example.com. 3600 IN CAA 0 issue "amazontrust.com"
; example.com. 3600 IN CAA 0 issue "awstrust.com"
; example.com. 3600 IN CAA 0 issue "amazonaws.com"
; example.com. 3600 IN CAA 0 issue "globalsign.com"
; if the record was locked to an old ACME account, update or drop accounturi
; example.com. 3600 IN CAA 0 issue "letsencrypt.org;accounturi=https://acme-v02.api.letsencrypt.org/acme/acct/NEWACCTID"
; get notified about denied issuance attempts
example.com. 3600 IN CAA 0 iodef "mailto:security@example.com"
Apply the change through the Cloudflare API
If your DNS is on Cloudflare, add the record through the Dashboard or the API. The API call below creates an issue record naming Let's Encrypt as an example.
curl -X POST "https://api.cloudflare.com/client/v4/zones/{zone_id}/dns_records" \
-H "Authorization: Bearer {CLOUDFLARE_API_TOKEN}" \
-H "Content-Type: application/json" \
--data '{"type":"CAA","name":"example.com","data":{"flags":0,"tag":"issue","value":"letsencrypt.org"},"ttl":3600}'
How to check it worked
Re-query the CAA record and confirm the intended CA's identifier string now appears, then re-trigger issuance and confirm the certificate was actually issued by that CA.
dig +short CAA example.com
# good result: 0 issue "letsencrypt.org" (or whichever CA you added)
# with no conflicting 0 issue ";" remaining
dig @1.1.1.1 CAA example.com +short
# good result: matches what you just set, from an uncached resolver
openssl s_client -connect example.com:443 -servername example.com /dev/null \
| openssl x509 -noout -issuer -dates
# good result: issuer matches the CA you just allowed, notBefore is fresh,
# and no ACME "urn:ietf:params:acme:error:caa" appears in the client log
The full code
Here is a script that walks up the DNS tree from a name to find the nearest CAA record set, decides whether the intended CA is permitted using a pure function, and, if not, adds the missing issue record through the Cloudflare API. It stays in dry run by default so it reports before it writes.
View this code on GitHub Full runnable folder with tests in the dns-fixes repo.
"""Detect a CAA record that blocks the intended certificate authority and,
on repair, add the missing issue record through the Cloudflare API. 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_blocks_intended_ca")
def caa_permits_ca(records, intended_ca_domain, is_wildcard=False):
"""Pure decision function. No DNS I/O, no network calls.
records: list of (tag, value) pairs from the nearest non-empty CAA
RRset found while walking up the DNS tree from the target
name to the apex (empty list means no CAA record exists
anywhere, so any CA is permitted).
intended_ca_domain: the CA's CAA identifier, e.g. "letsencrypt.org",
"digicert.com", "pki.goog".
is_wildcard: True if the certificate being requested is a wildcard
cert (checks the "issuewild" tag, falling back to "issue"
per RFC 8659 if no issuewild record is present).
Returns (permitted: bool, reason: str).
"""
if not records:
return True, "no CAA record anywhere in the tree, any CA is permitted"
tag = "issuewild" if is_wildcard else "issue"
tagged = [value for record_tag, value in records if record_tag == tag]
if is_wildcard and not tagged:
tagged = [value for record_tag, value in records if record_tag == "issue"]
tag = "issue"
if not tagged:
return True, f"no {tag} record present, so no restriction applies to this tag"
for value in tagged:
if value.strip() == ";":
return False, f'{tag} record is empty (0 {tag} ";")'
for value in tagged:
ca_part = value.split(";", 1)[0].strip()
if ca_part == intended_ca_domain:
return True, f"{tag} record names {intended_ca_domain}"
return False, f"no {tag} record names {intended_ca_domain}"
def _climb_labels(name):
labels = name.rstrip(".").split(".")
for i in range(len(labels) - 1):
yield ".".join(labels[i:])
yield labels[-1]
def run():
# Imported lazily so the pure function above can be tested with no
# network libraries installed at all.
import dns.resolver
import requests
name = os.environ["DNS_DOMAIN"]
intended_ca = os.environ.get("INTENDED_CA", "letsencrypt.org")
is_wildcard = os.environ.get("IS_WILDCARD", "false").lower() == "true"
dry_run = os.environ.get("DRY_RUN", "true").lower() == "true"
zone_id = os.environ["CLOUDFLARE_ZONE_ID"]
api_token = os.environ["CLOUDFLARE_API_TOKEN"]
headers = {"Authorization": f"Bearer {api_token}", "Content-Type": "application/json"}
resolver = dns.resolver.Resolver()
records = []
checked_name = name
for candidate in _climb_labels(name):
try:
answer = resolver.resolve(candidate, "CAA")
records = [(r.tag.decode() if isinstance(r.tag, bytes) else r.tag,
r.value.decode() if isinstance(r.value, bytes) else r.value)
for r in answer]
checked_name = candidate
break
except dns.resolver.NoAnswer:
continue
except dns.resolver.NXDOMAIN:
continue
permitted, reason = caa_permits_ca(records, intended_ca, is_wildcard)
log.info("CAA at %s: %s", checked_name, reason)
if permitted:
log.info("No fix needed. %s is already permitted to issue.", intended_ca)
return
log.warning("Blocked: %s", reason)
if dry_run:
log.info(
"Dry run: would add CAA 0 %s \"%s\" to %s",
"issuewild" if is_wildcard else "issue", intended_ca, name,
)
return
resp = requests.post(
f"https://api.cloudflare.com/client/v4/zones/{zone_id}/dns_records",
headers=headers,
json={
"type": "CAA",
"name": name,
"data": {
"flags": 0,
"tag": "issuewild" if is_wildcard else "issue",
"value": intended_ca,
},
"ttl": 3600,
},
timeout=30,
)
resp.raise_for_status()
log.info("Added CAA record permitting %s to issue for %s", intended_ca, name)
if __name__ == "__main__":
run()
/**
* Detect a CAA record that blocks the intended certificate authority and,
* on repair, add the missing issue record through the Cloudflare API. Safe
* to run on a schedule. Stays in dry run until DRY_RUN=false.
*/
import { pathToFileURL } from "node:url";
export function caaPermitsCa(records, intendedCaDomain, isWildcard = false) {
// Pure decision function. No DNS I/O, no network calls.
//
// records: array of [tag, value] pairs from the nearest non-empty CAA
// RRset found while walking up the DNS tree from the target
// name to the apex (empty array means no CAA record exists
// anywhere, so any CA is permitted).
// intendedCaDomain: the CA's CAA identifier, e.g. "letsencrypt.org",
// "digicert.com", "pki.goog".
// isWildcard: true if the certificate being requested is a wildcard
// cert (checks the "issuewild" tag, falling back to "issue"
// per RFC 8659 if no issuewild record is present).
//
// Returns [permitted, reason].
if (records.length === 0) {
return [true, "no CAA record anywhere in the tree, any CA is permitted"];
}
let tag = isWildcard ? "issuewild" : "issue";
let tagged = records.filter(([recordTag]) => recordTag === tag).map(([, value]) => value);
if (isWildcard && tagged.length === 0) {
tag = "issue";
tagged = records.filter(([recordTag]) => recordTag === tag).map(([, value]) => value);
}
if (tagged.length === 0) {
return [true, `no ${tag} record present, so no restriction applies to this tag`];
}
for (const value of tagged) {
if (value.trim() === ";") {
return [false, `${tag} record is empty (0 ${tag} ";")`];
}
}
for (const value of tagged) {
const caPart = value.split(";")[0].trim();
if (caPart === intendedCaDomain) {
return [true, `${tag} record names ${intendedCaDomain}`];
}
}
return [false, `no ${tag} record names ${intendedCaDomain}`];
}
function* climbLabels(name) {
const labels = name.replace(/\.$/, "").split(".");
for (let i = 0; i < labels.length - 1; i++) {
yield labels.slice(i).join(".");
}
yield labels[labels.length - 1];
}
export async function run() {
// Imported lazily so the pure function above can be tested with no
// network modules touched at all.
const dns = await import("node:dns");
const resolvePromises = dns.promises;
const name = process.env.DNS_DOMAIN;
const intendedCa = process.env.INTENDED_CA || "letsencrypt.org";
const isWildcard = (process.env.IS_WILDCARD || "false").toLowerCase() === "true";
const dryRun = (process.env.DRY_RUN || "true").toLowerCase() === "true";
const zoneId = process.env.CLOUDFLARE_ZONE_ID;
const apiToken = process.env.CLOUDFLARE_API_TOKEN;
const headers = { Authorization: `Bearer ${apiToken}`, "Content-Type": "application/json" };
let records = [];
let checkedName = name;
for (const candidate of climbLabels(name)) {
try {
const answer = await resolvePromises.resolveCaa(candidate);
if (answer && answer.length > 0) {
records = answer.map((r) => [r.critical !== undefined ? r.issue ? "issue" : (r.issuewild ? "issuewild" : (r.iodef ? "iodef" : "issue")) : "issue",
r.issue || r.issuewild || r.iodef || ""]);
checkedName = candidate;
break;
}
} catch (err) {
if (err.code !== "ENODATA" && err.code !== "ENOTFOUND") throw err;
}
}
const [permitted, reason] = caaPermitsCa(records, intendedCa, isWildcard);
console.log(`CAA at ${checkedName}: ${reason}`);
if (permitted) {
console.log(`No fix needed. ${intendedCa} is already permitted to issue.`);
return;
}
console.warn(`Blocked: ${reason}`);
const tag = isWildcard ? "issuewild" : "issue";
if (dryRun) {
console.log(`Dry run: would add CAA 0 ${tag} "${intendedCa}" to ${name}`);
return;
}
const res = await fetch(
`https://api.cloudflare.com/client/v4/zones/${zoneId}/dns_records`,
{
method: "POST",
headers,
body: JSON.stringify({
type: "CAA",
name,
data: { flags: 0, tag, value: intendedCa },
ttl: 3600,
}),
},
);
if (!res.ok) throw new Error(`Cloudflare API returned ${res.status}`);
console.log(`Added CAA record permitting ${intendedCa} to issue for ${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 permission check is the part worth testing on its own, because it decides whether the script thinks a CA is blocked at all. It takes plain tuples and strings and does no DNS work, so the test needs no network and no DNS library.
from caa_blocks_intended_ca import caa_permits_ca
def test_no_caa_records_permits_any_ca():
permitted, _ = caa_permits_ca([], "letsencrypt.org")
assert permitted is True
def test_matching_issue_record_permits():
records = [("issue", "letsencrypt.org")]
permitted, _ = caa_permits_ca(records, "letsencrypt.org")
assert permitted is True
def test_mismatched_issue_record_blocks():
records = [("issue", "digicert.com")]
permitted, reason = caa_permits_ca(records, "letsencrypt.org")
assert permitted is False
assert "no issue record names letsencrypt.org" in reason
def test_empty_issue_record_blocks_everyone():
records = [("issue", ";")]
permitted, reason = caa_permits_ca(records, "letsencrypt.org")
assert permitted is False
assert "empty" in reason
def test_wildcard_falls_back_to_issue_when_no_issuewild():
records = [("issue", "letsencrypt.org")]
permitted, _ = caa_permits_ca(records, "letsencrypt.org", is_wildcard=True)
assert permitted is True
def test_wildcard_uses_issuewild_when_present():
records = [("issue", "letsencrypt.org"), ("issuewild", "digicert.com")]
permitted, reason = caa_permits_ca(records, "letsencrypt.org", is_wildcard=True)
assert permitted is False
assert "issuewild" in reason
import { test } from "node:test";
import assert from "node:assert/strict";
import { caaPermitsCa } from "./caa-blocks-intended-ca.js";
test("no CAA records permits any CA", () => {
const [permitted] = caaPermitsCa([], "letsencrypt.org");
assert.equal(permitted, true);
});
test("matching issue record permits", () => {
const [permitted] = caaPermitsCa([["issue", "letsencrypt.org"]], "letsencrypt.org");
assert.equal(permitted, true);
});
test("mismatched issue record blocks", () => {
const [permitted, reason] = caaPermitsCa([["issue", "digicert.com"]], "letsencrypt.org");
assert.equal(permitted, false);
assert.match(reason, /no issue record names letsencrypt.org/);
});
test("empty issue record blocks everyone", () => {
const [permitted, reason] = caaPermitsCa([["issue", ";"]], "letsencrypt.org");
assert.equal(permitted, false);
assert.match(reason, /empty/);
});
test("wildcard falls back to issue when no issuewild", () => {
const [permitted] = caaPermitsCa([["issue", "letsencrypt.org"]], "letsencrypt.org", true);
assert.equal(permitted, true);
});
test("wildcard uses issuewild when present", () => {
const records = [["issue", "letsencrypt.org"], ["issuewild", "digicert.com"]];
const [permitted, reason] = caaPermitsCa(records, "letsencrypt.org", true);
assert.equal(permitted, false);
assert.match(reason, /issuewild/);
});
Case studies
The switch to Let's Encrypt that nobody told DNS about
A team moved their certificate automation from a paid CA to Let's Encrypt to cut renewal overhead. The ACME client ran cleanly, validation passed, and then every issuance attempt failed with a CAA error. The old CAA record, set up years earlier, still only named the previous CA.
A quick dig +short CAA showed the mismatch immediately. Adding an issue record for letsencrypt.org alongside the existing one let both CAs issue during the transition, and the old entry was removed once the migration was confirmed complete.
The restrictive record the DNS host added on its own
A store moved its nameservers to a new DNS host, which automatically created a CAA record naming only its own partner CA, as a default security measure. Nobody on the team asked for it and nobody noticed until a routine certificate renewal for a completely different CA started failing.
Checking the apex and the subdomain both showed the same auto-added record. The fix was adding an issue entry for the CA actually in use and an iodef record so future denied attempts would trigger an email instead of a silent renewal failure.
Once the CAA record names every CA you actually use, issuance completes without a CAA error from the ACME client or the CA's own dashboard, and the live certificate's issuer matches the CA that just issued it. Keep the record as tight as practical, only listing the CAs you actually rely on, and add an iodef record so any future denied attempt reaches you by email instead of just failing silently.
FAQ
Why does my certificate request keep failing with a CAA error?
Your domain has a CAA record that lists which certificate authorities are allowed to issue for it, and the CA you are actually using is not on that list. Every CA is required to check this record before issuing and must refuse if it is not named. Add an issue record for the CA you want to use and the error goes away.
What does 0 issue ";" mean in a CAA record?
That is an empty issue record, and it blocks every certificate authority from issuing for the domain, not just one. If any CA needs to issue a certificate, replace it with an explicit issue record naming that CA, such as letsencrypt.org, or remove the CAA record entirely.
Do I need a separate CAA record for wildcard certificates?
Only if you want wildcard issuance to be allowed by a different set of CAs than regular certificates. Add an issuewild record for that case. If no issuewild record exists, CAs fall back to checking the issue record for wildcard requests too, per RFC 8659.
Related field notes
Citations
On the problem:
- RFC 8659: DNS Certification Authority Authorization (CAA) Resource Record. rfc-editor.org/rfc/rfc8659.html
- Let's Encrypt docs: Certification Authority Authorization (CAA). letsencrypt.org/docs/caa
- AWS Certificate Manager docs: CAA problems when issuing or renewing a certificate. docs.aws.amazon.com/acm/latest/userguide/troubleshooting-caa.html
On the solution:
- Cloudflare docs: add CAA records. developers.cloudflare.com/ssl/edge-certificates/caa-records
- Cloudflare API: DNS records, CAA record model. developers.cloudflare.com/api/resources/dns/subresources/records/models/caa_record
- AWS re:Post: resolve CAA errors for issuing or renewing an ACM certificate. repost.aws/knowledge-center/acm-troubleshoot-caa-errors
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 issuance?
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