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.

dig and openssl Python and Node.js Fixable through the DNS API
A golden padlock sitting on top of a keyboard
Photo by Towfiqu barbhuiya on Unsplash
The short answer

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.

Request *.example.com wildcard certificate Check apex CAA finds issuewild tag issuewild overrides issue CAA policy error wildcard rejected Base domain still fine
The wildcard lookup checks the same apex CAA record, but the issuewild tag is consulted instead of issue, so the same CA that works for the base name gets rejected for the wildcard.

Why it happens

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.

The key insight

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.

Query apex CAA dig +short CAA Split tags issue vs issuewild Compare CAs named in each tag Tags agree? no, mismatch found Fix issuewild match issue, or delete it yes, done
Once issue and issuewild name the same CA, or issuewild is removed entirely, wildcard requests are governed by the same rule as the base domain again.

How to fix it

1

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.

Terminal
check-caa (shell)
dig +short CAA example.com
2

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.

Terminal
check-issuewild (shell)
dig +short CAA example.com | grep issuewild
3

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.

Terminal
trace-caa (shell)
dig +trace CAA example.com
4

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.

Terminal
try-wildcard (shell)
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)
5

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.

DNS record
zone record
; 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"
6

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.

Terminal
apply-fix (shell)
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.

Terminal
verify (shell)
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.

wildcard_caa_check.py
"""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()
wildcard-caa-check.js
/**
 * 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.

test_caa_wildcard.py
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 == ""
wildcard-caa-check.test.js
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

Leftover deny-all policy

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.

CDN auto-inserted record

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.

What good looks like

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:

  1. RFC 8659: DNS Certification Authority Authorization (CAA) Resource Record. rfc-editor.org/rfc/rfc8659.html
  2. Let's Encrypt: Certificate Authority Authorization (CAA). letsencrypt.org/docs/caa
  3. 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:

  1. Let's Encrypt: Certificate Authority Authorization (CAA), setting issue and issuewild. letsencrypt.org/docs/caa
  2. Cloudflare API: DNS Records, create, update, and delete. developers.cloudflare.com/api/resources/dns/subresources/records
  3. 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.

Contact me on LinkedIn

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

Back to all DNS and Domains field notes