Diagnostic TLS Certificates / CAA
CAA blocks wildcard certificate issuance specifically
The base domain gets its certificate without any trouble. But every request for a wildcard, like *.example.com, fails with a CAA policy error from the same certificate authority that just issued fine a minute ago. This is one of the more confusing CAA problems because the plain-looking dig output for the base domain hides the record that is actually blocking you. Here is why an issuewild record causes this, and how to find and fix it.
A CAA issuewild record at the apex governs wildcard certificate requests only, and per RFC 8659, once any issuewild record exists it completely overrides issue for wildcard names. If that issuewild record names a different CA than issue, or is set to issuewild ";" (deny all), the base domain issues fine while every wildcard request fails with a CAA policy error. Prove it with dig +short CAA example.com and check the issuewild line specifically. The fix is to add or correct an issuewild record naming the same CA as issue, or delete the stray issuewild record entirely so wildcards fall back to issue. Full commands, records, and a small checker script are below.
The problem in plain words
A CAA record tells certificate authorities which of them are allowed to issue for a domain. Most zones have one line, an issue tag naming the CA, like letsencrypt.org. That one line covers both the base domain and any wildcard under it, as long as nothing else says otherwise.
The trouble starts when a second, more specific tag shows up: issuewild. Per RFC 8659, this tag exists to give wildcard names their own rule, separate from the base domain. But it does not add to the issue rule, it replaces it for wildcards. So if someone adds an issuewild record naming a different CA, or a security policy leaves behind issuewild ";" to explicitly deny every wildcard issuer, the base domain keeps working under issue while the wildcard silently stops working, using the very same CA.
Why it happens
- An
issuewildrecord is added at some point naming a different CA than theissuerecord, often because a second CA was tried and never fully switched over, or a template from a different provider left its own value behind. - A "deny all wildcards" security policy adds
issuewild ";"on purpose, and it is never removed once the policy changes or the team decides wildcards are needed again. - A CDN or hosting platform inserts its own CAA records automatically when a domain is connected, including an
issuewildtag that does not match the CA the store actually uses for its own certificates. - Someone reads
dig +short CAA example.com, sees the firstissueline looks correct, and stops looking, missing the separateissuewildline further down that governs wildcards only.
This is reported often enough that it has its own well known shape in ACME client error logs: the client reports success for the bare domain identifier and a CAA-specific rejection for the wildcard identifier in the same order, on the same run. See the citations at the end for the exact threads.
Per RFC 8659, issuewild is not an addition to issue, it is a replacement for wildcard names. The moment any issuewild record exists in a CAA RRset, it is the only thing consulted for wildcard requests. The issue record is never even read for a wildcard name once an issuewild record is present, no matter how correct that issue record looks.
The fix, as a flow
Confirm the CAA records at the apex, separate the issue tag from the issuewild tag, and check whether they name the same CA. If they disagree, or issuewild is a deny-all value, either correct issuewild to match issue or delete it so wildcards fall back to issue on their own.
How to fix it
Query the apex CAA records
List every CAA record at the apex. Do not stop at the first line. A domain can carry more than one CAA record, and the ones that matter here are the tags, not just the CA name.
dig +short CAA example.com
Isolate the wildcard-specific rule
Filter for the issuewild tag by itself. This is the line that governs wildcard names only, and it is easy to miss when you only glance at the first result.
dig +short CAA example.com | grep issuewild
Confirm no closer zone injects a conflicting record
Trace the full path to rule out a delegated subdomain adding its own CAA record closer to the name than the apex. Wildcard lookups climb from the wildcard's parent, so this confirms you are reading the record that actually applies.
dig +trace CAA example.com
Try the wildcard issuance and read the exact error
Run a dry run wildcard order through your ACME client. The error message will name the identifier that failed, which for this problem is always the wildcard, never the base domain.
certbot certonly --manual -d '*.example.com' -d example.com --dry-run
# bad result: caa: CAA record for example.com prevents issuance
# (reported for the *.example.com identifier only)
Correct or remove the issuewild record
Add or edit the issuewild record so it names the same CA identification domain as issue. If there is a stray issuewild ";" record left over from a deny-all policy, delete it. If you do not need a different rule for wildcards at all, the simplest fix is to remove every issuewild record and let issue govern both cases, per RFC 8659 section 4.2.
; bad: issuewild names a different CA than issue, or denies everything
example.com. 3600 IN CAA 0 issue "letsencrypt.org"
example.com. 3600 IN CAA 0 issuewild "sectigo.com"
; or:
example.com. 3600 IN CAA 0 issuewild ";"
; good: issuewild matches issue, so the same CA works for both
example.com. 3600 IN CAA 0 issue "letsencrypt.org"
example.com. 3600 IN CAA 0 issuewild "letsencrypt.org"
; also good: no issuewild at all, issue governs both non-wildcard and wildcard
example.com. 3600 IN CAA 0 issue "letsencrypt.org"
Apply the change through the Cloudflare API
Find the CAA record with tag issuewild by listing CAA records in the zone, then update its value to match issue, or delete it if you are dropping the wildcard-specific rule entirely.
curl -X DELETE "https://api.cloudflare.com/client/v4/zones/{zone_id}/dns_records/{record_id}" \
-H "Authorization: Bearer {CLOUDFLARE_API_TOKEN}"
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":"issuewild","value":"letsencrypt.org"}}'
How to check it worked
Re-query the CAA records to confirm the fix landed, re-run the wildcard order, and check the certificate that actually gets served.
dig +short CAA example.com
# good result: issuewild names the intended CA, or is absent entirely
certbot certonly --manual -d '*.example.com' --dry-run
# good result: completes with no caa error
echo | openssl s_client -connect example.com:443 -servername sub.example.com 2>/dev/null | openssl x509 -noout -subject -ext subjectAltName
# good result: subjectAltName includes DNS:*.example.com
The full code
Here is a small script that fetches the apex CAA RRset with dnspython, parses every issue and issuewild tag, and flags a mismatch: an issuewild value that differs from every issue value, or an issuewild set to ";", while at least one issue value matches the CA the ACME client uses. When run as a repair, it finds the offending record through the Cloudflare API and updates it to match. 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 an issuewild CAA record that blocks wildcard certificate issuance
and, on repair, correct it 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("wildcard_caa_check")
def wildcard_caa_blocked(caa_records, desired_ca):
"""Pure decision function. No network, no I/O.
caa_records is a list of (flags, tag, value) tuples parsed from the
apex CAA RRset. desired_ca is the CA identification domain the ACME
client will use, such as "letsencrypt.org".
Returns (True, reason) if any issuewild record exists whose value is
not desired_ca or equals ";" (explicit deny), while at least one
issue record equals desired_ca. That combination means non-wildcard
issuance would succeed but wildcard issuance would fail.
Returns (False, "") otherwise: no issuewild present, or issuewild
already matches desired_ca.
"""
issue_values = [value for (_, tag, value) in caa_records if tag == "issue"]
issuewild_values = [value for (_, tag, value) in caa_records if tag == "issuewild"]
if not issuewild_values:
return (False, "")
if desired_ca not in issue_values:
return (False, "")
for value in issuewild_values:
if value == ";":
return (True, f"issuewild is set to deny all wildcard issuers (\";\"), but issue allows {desired_ca}")
if value != desired_ca:
return (True, f"issuewild names {value}, which does not match the issue value {desired_ca}")
return (False, "")
def run():
# Imported lazily so the pure function above can be tested with no
# network libraries installed at all.
import dns.resolver
import requests
zone_apex = os.environ["DNS_DOMAIN"]
desired_ca = os.environ.get("DESIRED_CA", "letsencrypt.org")
dry_run = os.environ.get("DRY_RUN", "true").lower() == "true"
answer = dns.resolver.resolve(zone_apex, "CAA")
caa_records = []
for rdata in answer:
tag = rdata.tag.decode() if isinstance(rdata.tag, bytes) else rdata.tag
value = rdata.value.decode() if isinstance(rdata.value, bytes) else rdata.value
caa_records.append((rdata.flags, tag, value))
blocked, reason = wildcard_caa_blocked(caa_records, desired_ca)
if not blocked:
log.info("No wildcard-blocking issuewild mismatch found for %s", zone_apex)
return
log.warning("Wildcard issuance blocked for %s: %s", zone_apex, reason)
zone_id = os.environ["CLOUDFLARE_ZONE_ID"]
api_token = os.environ["CLOUDFLARE_API_TOKEN"]
headers = {"Authorization": f"Bearer {api_token}", "Content-Type": "application/json"}
resp = requests.get(
f"https://api.cloudflare.com/client/v4/zones/{zone_id}/dns_records",
headers=headers,
params={"type": "CAA"},
timeout=30,
)
resp.raise_for_status()
records = resp.json()["result"]
offending = next((r for r in records if r.get("data", {}).get("tag") == "issuewild"), None)
if dry_run:
if offending:
log.info("Dry run: would update record %s issuewild to %s", offending["id"], desired_ca)
else:
log.info("Dry run: would create a new issuewild record set to %s", desired_ca)
return
payload = {
"type": "CAA",
"name": zone_apex,
"data": {"flags": 0, "tag": "issuewild", "value": desired_ca},
}
if offending:
put_resp = requests.put(
f"https://api.cloudflare.com/client/v4/zones/{zone_id}/dns_records/{offending['id']}",
headers=headers,
json=payload,
timeout=30,
)
put_resp.raise_for_status()
log.info("Updated issuewild record for %s to %s", zone_apex, desired_ca)
else:
post_resp = requests.post(
f"https://api.cloudflare.com/client/v4/zones/{zone_id}/dns_records",
headers=headers,
json=payload,
timeout=30,
)
post_resp.raise_for_status()
log.info("Created issuewild record for %s set to %s", zone_apex, desired_ca)
if __name__ == "__main__":
run()
/**
* Detect an issuewild CAA record that blocks wildcard certificate issuance
* and, on repair, correct it 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 wildcardCaaBlocked(caaRecords, desiredCa) {
// Pure decision function. No network, no I/O.
//
// caaRecords is an array of [flags, tag, value] tuples parsed from the
// apex CAA RRset. desiredCa is the CA identification domain the ACME
// client will use, such as "letsencrypt.org".
//
// Returns [true, reason] if any issuewild record exists whose value is
// not desiredCa or equals ";" (explicit deny), while at least one issue
// record equals desiredCa. That combination means non-wildcard issuance
// would succeed but wildcard issuance would fail.
//
// Returns [false, ""] otherwise: no issuewild present, or issuewild
// already matches desiredCa.
const issueValues = caaRecords.filter(([, tag]) => tag === "issue").map(([, , value]) => value);
const issuewildValues = caaRecords.filter(([, tag]) => tag === "issuewild").map(([, , value]) => value);
if (issuewildValues.length === 0) {
return [false, ""];
}
if (!issueValues.includes(desiredCa)) {
return [false, ""];
}
for (const value of issuewildValues) {
if (value === ";") {
return [true, `issuewild is set to deny all wildcard issuers (";"), but issue allows ${desiredCa}`];
}
if (value !== desiredCa) {
return [true, `issuewild names ${value}, which does not match the issue value ${desiredCa}`];
}
}
return [false, ""];
}
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 zoneApex = process.env.DNS_DOMAIN;
const desiredCa = process.env.DESIRED_CA || "letsencrypt.org";
const dryRun = (process.env.DRY_RUN || "true").toLowerCase() === "true";
const answers = await resolvePromises.resolveCaa(zoneApex);
// node:dns resolveCaa returns { critical, issue } or { critical, issuewild } shaped
// entries, so normalize into (flags, tag, value) tuples first.
const normalized = answers.map((a) => {
const tag = a.issue !== undefined ? "issue" : a.issuewild !== undefined ? "issuewild" : "unknown";
const value = a.issue !== undefined ? a.issue : a.issuewild;
return [a.critical ? 128 : 0, tag, value];
});
const [blocked, reason] = wildcardCaaBlocked(normalized, desiredCa);
if (!blocked) {
console.log(`No wildcard-blocking issuewild mismatch found for ${zoneApex}`);
return;
}
console.warn(`Wildcard issuance blocked for ${zoneApex}: ${reason}`);
const zoneId = process.env.CLOUDFLARE_ZONE_ID;
const apiToken = process.env.CLOUDFLARE_API_TOKEN;
const headers = { Authorization: `Bearer ${apiToken}`, "Content-Type": "application/json" };
const listRes = await fetch(
`https://api.cloudflare.com/client/v4/zones/${zoneId}/dns_records?type=CAA`,
{ headers },
);
if (!listRes.ok) throw new Error(`Cloudflare API returned ${listRes.status}`);
const { result: records } = await listRes.json();
const offending = records.find((r) => r.data && r.data.tag === "issuewild");
const payload = {
type: "CAA",
name: zoneApex,
data: { flags: 0, tag: "issuewild", value: desiredCa },
};
if (dryRun) {
if (offending) {
console.log(`Dry run: would update record ${offending.id} issuewild to ${desiredCa}`);
} else {
console.log(`Dry run: would create a new issuewild record set to ${desiredCa}`);
}
return;
}
if (offending) {
const putRes = await fetch(
`https://api.cloudflare.com/client/v4/zones/${zoneId}/dns_records/${offending.id}`,
{ method: "PUT", headers, body: JSON.stringify(payload) },
);
if (!putRes.ok) throw new Error(`Cloudflare API returned ${putRes.status}`);
console.log(`Updated issuewild record for ${zoneApex} to ${desiredCa}`);
} else {
const postRes = await fetch(
`https://api.cloudflare.com/client/v4/zones/${zoneId}/dns_records`,
{ method: "POST", headers, body: JSON.stringify(payload) },
);
if (!postRes.ok) throw new Error(`Cloudflare API returned ${postRes.status}`);
console.log(`Created issuewild record for ${zoneApex} set to ${desiredCa}`);
}
}
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 blocking check is the part worth testing on its own, because it decides whether a wildcard issuance will fail before you ever touch the ACME client. It takes plain tuples and a string and does simple comparisons, so the test needs no network and no DNS library at all.
from wildcard_caa_check import wildcard_caa_blocked
def test_blocked_when_issuewild_names_different_ca():
records = [(0, "issue", "letsencrypt.org"), (0, "issuewild", "sectigo.com")]
blocked, reason = wildcard_caa_blocked(records, "letsencrypt.org")
assert blocked is True
assert "sectigo.com" in reason
def test_blocked_when_issuewild_denies_all():
records = [(0, "issue", "letsencrypt.org"), (0, "issuewild", ";")]
blocked, reason = wildcard_caa_blocked(records, "letsencrypt.org")
assert blocked is True
assert "deny all" in reason
def test_not_blocked_when_issuewild_matches():
records = [(0, "issue", "letsencrypt.org"), (0, "issuewild", "letsencrypt.org")]
blocked, reason = wildcard_caa_blocked(records, "letsencrypt.org")
assert blocked is False
assert reason == ""
def test_not_blocked_when_no_issuewild_present():
records = [(0, "issue", "letsencrypt.org")]
blocked, reason = wildcard_caa_blocked(records, "letsencrypt.org")
assert blocked is False
assert reason == ""
import { test } from "node:test";
import assert from "node:assert/strict";
import { wildcardCaaBlocked } from "./wildcard-caa-check.js";
test("blocked when issuewild names different ca", () => {
const records = [[0, "issue", "letsencrypt.org"], [0, "issuewild", "sectigo.com"]];
const [blocked, reason] = wildcardCaaBlocked(records, "letsencrypt.org");
assert.equal(blocked, true);
assert.match(reason, /sectigo\.com/);
});
test("blocked when issuewild denies all", () => {
const records = [[0, "issue", "letsencrypt.org"], [0, "issuewild", ";"]];
const [blocked, reason] = wildcardCaaBlocked(records, "letsencrypt.org");
assert.equal(blocked, true);
assert.match(reason, /deny all/);
});
test("not blocked when issuewild matches", () => {
const records = [[0, "issue", "letsencrypt.org"], [0, "issuewild", "letsencrypt.org"]];
const [blocked, reason] = wildcardCaaBlocked(records, "letsencrypt.org");
assert.equal(blocked, false);
assert.equal(reason, "");
});
test("not blocked when no issuewild present", () => {
const records = [[0, "issue", "letsencrypt.org"]];
const [blocked, reason] = wildcardCaaBlocked(records, "letsencrypt.org");
assert.equal(blocked, false);
assert.equal(reason, "");
});
Case studies
The wildcard block nobody remembered setting
A store had briefly banned wildcard certificates during a security review two years earlier, adding example.com. CAA 0 issuewild ";" to the zone. The base domain renewed on schedule every quarter without issue, so nobody noticed anything wrong until a new subdomain-per-tenant feature needed a wildcard certificate and every attempt failed with a CAA error.
Running dig +short CAA example.com | grep issuewild found the old deny-all record immediately. Deleting it let the wildcard issue on the very next try, using the same Let's Encrypt account that had been renewing the base domain the whole time.
The host that added its own CA behind the scenes
A team moved DNS to a new CDN, which automatically inserted CAA records to protect the zone, including its own issuewild tag naming its own managed CA. The store still issued and renewed its base domain certificate with Let's Encrypt normally, since issue was untouched, but a wildcard order for a new marketing subdomain failed with a CAA policy error that mentioned the CDN's CA, not Let's Encrypt.
Comparing issue and issuewild side by side showed the mismatch right away. Updating issuewild to match the CA the store actually used fixed the wildcard order without touching anything else in the zone.
Once issuewild matches issue, or is removed entirely, a wildcard order for the same CA that already issues the base domain succeeds on the first try. dig +short CAA example.com shows consistent CAs across every tag, the ACME dry run for *.example.com completes with no CAA error, and the certificate actually served for a subdomain carries DNS:*.example.com in its subject alternative names.
FAQ
Why does my base domain get a certificate fine but the wildcard fails?
An issuewild CAA record at the apex only governs wildcard requests, and per RFC 8659 it completely overrides the issue record once it exists. If issuewild names a different CA than issue, or is set to deny everything, the base domain still issues fine under issue while every wildcard request from the same CA is rejected.
What does issuewild ";" mean in a CAA record?
It means deny all wildcard issuance from any CA. It is often left over from a policy that intentionally blocked wildcards, or added by a host or CDN automatically. As long as it stays in the zone, no CA can issue a wildcard certificate for that domain, even one otherwise allowed by the issue record.
Do I need a separate issuewild record if I already have an issue record?
No. Per RFC 8659, when no issuewild record exists, the issue record governs both non-wildcard and wildcard requests. You only need issuewild when you want a different rule for wildcards than for the base name, and if you add one it must name the same CA you actually use, or wildcard issuance will fail.
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: Certificate Authority Authorization (CAA). letsencrypt.org/docs/caa
- Let's Encrypt Community: CAA record suddenly failing for wildcard certificates. community.letsencrypt.org/t/caa-record-suddenly-failing-for-wildcard-certificates/190340
On the solution:
- Let's Encrypt: Certificate Authority Authorization (CAA), setting issue and issuewild. letsencrypt.org/docs/caa
- Cloudflare API: DNS Records, create, update, and delete. developers.cloudflare.com/api/resources/dns/subresources/records
- Let's Encrypt Community: CAA question, issue versus issuewild. community.letsencrypt.org/t/caa-question-issue-issuewild/205567
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 unblock your wildcard certificate?
If this saved you a support ticket or a late night reading ACME logs, 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