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.

dig and openssl Python and Node.js Fixable through the DNS API
Red padlock on black computer keyboard
Photo by FlyD on Unsplash
The short answer

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.

Let's Encrypt asks to issue a cert CAA lookup 0 issue "digicert.com" name not listed CA refuses per RFC 8659 caa error no cert issued
The CAA record names a different CA, so the CA you actually asked to issue is required to refuse, and the request fails with a CAA error.

Why it happens

The key insight

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.

List CAA records up to the apex Compare CA name vs. intended CA Edit issue record add the intended CA CAA now permits it? yes no, check accounturi Re-trigger issuance confirm cert issues
Once the CAA record names the CA you are actually using, that CA's own issuance check passes and the certificate request goes through.

How to fix it

1

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.

Terminal
check-caa (shell)
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"
2

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.

Terminal
check-resolvers (shell)
dig @1.1.1.1 CAA example.com
dig @8.8.8.8 CAA example.com
3

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.

Terminal
check-account-and-issuer (shell)
# 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
4

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.

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

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.

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

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

caa_blocks_intended_ca.py
"""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()
caa-blocks-intended-ca.js
/**
 * 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.

test_caa_permits.py
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
caa-blocks-intended-ca.test.js
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

CA migration

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.

DNS host default

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.

What good looks like

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:

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

  1. Cloudflare docs: add CAA records. developers.cloudflare.com/ssl/edge-certificates/caa-records
  2. Cloudflare API: DNS records, CAA record model. developers.cloudflare.com/api/resources/dns/subresources/records/models/caa_record
  3. 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.

Contact me on LinkedIn

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

Back to all DNS and Domains field notes