Automating ACME DNS-01 Challenges for Wildcard Certificates

After working through this guide you will be able to issue and renew a wildcard TLS certificate entirely without human intervention: a scoped provider credential writes the _acme-challenge TXT record, a delegation CNAME keeps your production zone token out of the certificate host, and a systemd timer drives renewal months before expiry.

Wildcard certificates are the one case where you have no choice about the validation method. Everything else — the credential scoping, the delegation trick, the polling strategy, the timer — exists because DNS-01 hands a machine write access to a zone, and a zone is the single most destructive thing an automation account can hold. The build below spends most of its effort making that write access as narrow and as boring as possible.

Key implementation objectives:

  • Understand why *.example.com can only be proven through a TXT record, and how that record’s value is derived from your ACME account key.
  • Run certbot, lego, and acme.sh against a DNS provider plugin with a credential scoped to a single zone and record type.
  • Delegate _acme-challenge into a throwaway zone with a CNAME so the production zone token never reaches the certificate host.
  • Replace fixed propagation sleeps with authoritative polling, and drive renewal from a randomized systemd timer.

Why a wildcard forces DNS-01

The ACME protocol offers three challenge types. http-01 asks you to serve a file from /.well-known/acme-challenge/ on port 80 of a specific hostname; tls-alpn-01 asks for a special certificate on port 443 of a specific hostname. Both prove control of one name because both terminate a connection to that name. A wildcard identifier has no such name — there is no host called *.example.com to connect to — so neither method can carry the proof. dns-01 is the only challenge type a CA will accept for a wildcard, and it is also the only one that works for names with no public listener at all, which is why internal service certificates end up on the same machinery.

One detail trips people up on the first run: the wildcard authorization for *.example.com is answered at _acme-challenge.example.com, with no asterisk anywhere in the record name. If your order also includes the bare apex example.com, the CA creates two separate authorizations that both validate at that same record name, with two different token values. Both TXT records must exist simultaneously. A DNS provider plugin that replaces the record set instead of appending to it will pass one authorization and fail the other.

DNS-01 order sequence for a wildcard certificate Three participants exchange messages over time: the ACME client places an order, the CA returns a dns-01 challenge, the client writes a TXT record through the DNS provider, then the CA resolves the record from the authoritative servers and issues the certificate. ACME client certbot / lego ACME CA order + validator DNS provider API + auth NS newOrder: example.com + *.example.com two authorizations, one token each write TXT _acme-challenge.example.com record id returned, client polls the auth NS POST challenge: ready for validation resolve TXT from every perspective status valid, finalize, certificate issued

How the TXT value is derived

The string you publish is not the token the CA sent you. The client concatenates the token with a dot and the base64url-encoded SHA-256 digest of the JWK thumbprint of your ACME account key, producing the key authorization. It then hashes that whole string with SHA-256 and base64url-encodes it without padding, giving a 43-character record value.

Two consequences follow. First, the value is cryptographically bound to the account key, so a token intercepted in transit is useless to anyone who does not hold that key. Second, if you regenerate or lose the account key mid-flight, every outstanding authorization becomes unanswerable — you must start a new order rather than republish the old record.

How the _acme-challenge TXT value is computed The ACME account key thumbprint and the per-challenge token combine into a key authorization, which is hashed and base64url encoded to become the published TXT record value. ACME account key JWK thumbprint, RFC 7638 stable across orders Challenge token one per authorization random, short lived keyAuthorization token + "." + thumbprint never published as-is SHA-256 digest verbatim _acme-challenge.example.com. 60 IN TXT "base64url(SHA-256(keyAuthorization))" — 43 chars, no padding

Prerequisites and environment setup

You need a domain whose authoritative servers you control through an API, a machine that will hold the certificate, and one of the three clients below. Confirm versions before anything else, because DNS plugin flags moved between major releases:

certbot --version                 # 2.6.0 or newer
lego --version                    # 4.14 or newer
acme.sh --version                 # 3.0.7 or newer
dig -v                            # BIND 9.16+ for +yaml output

Install the provider plugin that matches your zone host. For certbot these are separate packages — python3-certbot-dns-cloudflare, python3-certbot-dns-route53, python3-certbot-dns-google — and the plugin name on the command line always mirrors the package. lego and acme.sh ship every provider in the single binary and select it with a flag or an environment variable.

Before you issue anything, confirm that your CAA records permit the CA you are about to use. A wildcard order is checked against issuewild first, and an issue record alone will not authorize it on every CA.

Step-by-step procedure

Step 1 — Cut a credential scoped to one zone

The default posture of most DNS API tokens is “edit everything in the account”. That is unacceptable on a machine that terminates TLS. On Cloudflare, create a token with a single policy: Zone → DNS → Edit, resource limited to the one zone, plus Zone → Zone → Read so the plugin can look up the zone id. Store it in a file the certificate host reads and nothing else does:

install -m 600 /dev/null /etc/letsencrypt/cloudflare.ini
cat > /etc/letsencrypt/cloudflare.ini <<'EOF'
dns_cloudflare_api_token = <scoped-token>
EOF

On Route 53 the equivalent is an IAM policy that pins both the hosted zone and the record name. The two condition keys below are the ones that make this policy meaningfully narrow — without them, ChangeResourceRecordSets on a zone is a full takeover of that zone:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": "route53:GetChange",
      "Resource": "arn:aws:route53:::change/*"
    },
    {
      "Effect": "Allow",
      "Action": "route53:ListHostedZones",
      "Resource": "*"
    },
    {
      "Effect": "Allow",
      "Action": "route53:ChangeResourceRecordSets",
      "Resource": "arn:aws:route53:::hostedzone/Z0123456789ABCDEFGHIJ",
      "Condition": {
        "ForAllValues:StringEquals": {
          "route53:ChangeResourceRecordSetsNormalizedRecordNames": [
            "_acme-challenge.example.com"
          ],
          "route53:ChangeResourceRecordSetsRecordTypes": ["TXT"]
        }
      }
    }
  ]
}

Side effect worth knowing: ListHostedZones cannot be narrowed to one zone, so the credential can always enumerate zone names. It just cannot change any of them.

Step 2 — Delegate _acme-challenge into a throwaway zone

Even a tightly scoped token is a token on a production zone. The delegation pattern removes it entirely: publish a permanent CNAME at _acme-challenge.example.com pointing into a separate zone that contains nothing of value, and give the certificate host credentials for that zone only. A CA follows CNAMEs when it resolves the challenge name, so validation succeeds while your production zone stays read-only from the host’s point of view.

Create the CNAME once, by hand or through your zone-as-code pipeline. If you already manage zones with Terraform, it belongs in the same state as the rest of the records:

# Cloudflare provider v5 uses `content`; on v4 the attribute is named `value`.
resource "cloudflare_record" "acme_delegation" {
  zone_id = var.production_zone_id
  name    = "_acme-challenge"
  type    = "CNAME"
  content = "example-com.acme.example.net"
  ttl     = 60
  proxied = false
  comment = "ACME DNS-01 delegation - target zone holds the rotating TXT"
}

resource "aws_route53_record" "acme_delegation_secondary" {
  zone_id = var.secondary_zone_id
  name    = "_acme-challenge.example.org"
  type    = "CNAME"
  ttl     = 60
  records = ["example-org.acme.example.net"]
}

The target name should be unique per apex so that two domains sharing the delegation zone never collide on the same record set.

Delegating the challenge record away from the production zone The certificate authority queries the production zone, follows a permanent CNAME into a throwaway zone, and reads the TXT there; the ACME client holds credentials only for the throwaway zone while the production zone token stays in the vault. CA validator asks for TXT at _acme-challenge example.com zone _acme-challenge CNAME static, never rewritten acme.example.net throwaway zone rotating TXT lives here CNAME followed Certificate host token scoped to the throwaway zone Secrets vault production zone token never leaves the vault one manual edit, then read-only A compromised certificate host can only rewrite records nobody depends on.

Step 3 — Issue the certificate with certbot

With the credential in place, a wildcard order is a single command. Quote the wildcard so the shell does not expand it against the working directory:

certbot certonly \
  --dns-cloudflare \
  --dns-cloudflare-credentials /etc/letsencrypt/cloudflare.ini \
  --dns-cloudflare-propagation-seconds 30 \
  --key-type ecdsa --elliptic-curve secp384r1 \
  --preferred-chain "ISRG Root X1" \
  --agree-tos --no-eff-email -m [email protected] \
  --non-interactive \
  -d example.com -d '*.example.com'

Certbot writes the answers, waits the propagation interval, tells the CA the challenges are ready, and persists everything it needs for renewal into /etc/letsencrypt/renewal/example.com.conf. That file — not the command line — is what certbot renew replays later, so a flag you forget here is a flag that is missing at renewal.

Step 4 — The same order with lego or acme.sh

lego is the better fit when you want the ACME client to be a single static binary with no Python runtime. It reads the provider credential from the environment and follows the delegation CNAME when you opt in:

export CLOUDFLARE_DNS_API_TOKEN="$(vault kv get -field=token secret/acme/throwaway)"
export CLOUDFLARE_TTL=60
export CLOUDFLARE_PROPAGATION_TIMEOUT=180
export LEGO_EXPERIMENTAL_CNAME_SUPPORT=true

lego --email [email protected] \
     --dns cloudflare \
     --dns.resolvers 1.1.1.1:53 \
     --domains example.com \
     --domains '*.example.com' \
     --key-type ec384 \
     --path /etc/lego \
     run

acme.sh expresses the same delegation with --challenge-alias, which is often the quickest way to retrofit the pattern onto an existing deployment:

acme.sh --issue --dns dns_cf \
  -d example.com -d '*.example.com' \
  --challenge-alias acme.example.net \
  --keylength ec-256 \
  --dnssleep 0 \
  --server letsencrypt

--dnssleep 0 is deliberate: it turns off the blind sleep so acme.sh polls the authoritative servers instead, which is the subject of the next step.

Step 5 — Poll authoritatively instead of sleeping

A fixed propagation sleep is a guess. Set it too low and the CA reads an empty record set; set it high enough to always be safe and every renewal burns minutes of wall clock. All three clients can instead query the zone’s own authoritative servers and proceed the moment the answer appears — that is what --dns.resolvers in lego and --dnssleep 0 in acme.sh switch on, and what certbot’s propagation seconds should be trimmed toward once you have measured the real interval.

Measuring it is worth ten minutes. Write a scratch record, then time how long each authoritative server takes to serve it:

for ns in $(dig +short NS acme.example.net); do
  printf '%s ' "$ns"
  dig +short TXT probe.acme.example.net "@${ns}"
done

Two things dominate the result. The first is your provider’s own propagation between its edge nodes, typically single-digit seconds on an anycast platform. The second is negative caching driven by the SOA minimum TTL: if anything queried the challenge name before you wrote the record, the NXDOMAIN answer is cached for the SOA minimum, and no amount of polling the authoritative servers will help the CA’s recursive resolvers. Keep the delegation zone’s SOA minimum at 60 seconds or lower and the problem disappears.

Step 6 — Drive renewal from a systemd timer

Renew at roughly one third of remaining lifetime, not at the last moment. A 90-day certificate renewed at day 60 leaves a 30-day window in which a failed attempt can be retried, alerted on, and fixed by a human during working hours. Two units do the whole job:

# /etc/systemd/system/acme-renew.service
[Unit]
Description=Renew ACME certificates
After=network-online.target
Wants=network-online.target

[Service]
Type=oneshot
ExecStart=/usr/bin/certbot renew --quiet --deploy-hook /usr/local/sbin/reload-tls
# /etc/systemd/system/acme-renew.timer
[Unit]
Description=Twice-daily ACME renewal check

[Timer]
OnCalendar=*-*-* 02,14:00:00
RandomizedDelaySec=7200
Persistent=true

[Install]
WantedBy=timers.target
systemctl enable --now acme-renew.timer
systemctl list-timers acme-renew.timer

RandomizedDelaySec matters more than it looks. Without it, every host in a fleet renews at exactly the same second, which is how a hundred machines discover a CA rate limit simultaneously. Persistent=true catches up a missed run after a host has been powered off.

Where in a 90-day lifetime the timer actually renews A timeline of a ninety day certificate: the twice-daily timer exits without work for the first sixty days, then renewal runs, leaving thirty days of retry room before expiry. acme-renew.timer twice daily + jitter certbot renew checks every renewal conf deploy hook reload TLS only days 0-60: renew exits "not due", no DNS writes days 60-90: renewal runs 30 days of retry room day 0 issued day 60 first attempt day 90 expiry Alert on certificates younger than 30 days remaining, not on the timer exit code.

Step 7 — Multi-account and multi-provider layouts

Once more than one team issues certificates, one ACME account for the whole company becomes a shared failure domain: account-level rate limits, a single key to rotate, and no way to tell whose automation misbehaved. Give each environment its own account directory and each zone its own credential:

certbot certonly --config-dir /etc/letsencrypt/prod   --dns-route53 -d '*.prod.example.com'
certbot certonly --config-dir /etc/letsencrypt/stage  --dns-cloudflare \
  --dns-cloudflare-credentials /etc/letsencrypt/stage/cf.ini -d '*.stage.example.com'

Where a single certificate spans names in two providers, certbot cannot mix plugins in one order — use lego with --dns per invocation and separate certificates, or point every _acme-challenge at one delegation zone so a single provider plugin answers all of them. The delegation pattern from Step 2 makes the second option the easy one: the production zones can live anywhere, because only the throwaway zone is ever written to.

Verification

Prove the delegation resolves before you ever run the client. The CNAME should be visible with no TXT behind it yet:

dig +noall +answer CNAME _acme-challenge.example.com
_acme-challenge.example.com. 60 IN CNAME example-com.acme.example.net.

During an issuance, watch the answer appear at the target name:

dig +short TXT example-com.acme.example.net
"lXqK2f1mA9uJ0pR7cV3sB6nT8dE4hG5yZ1wQ2xC0oI"
"7bN4tR9eL2vK6jM1sD8fH3gA5yU0pW7cX2zQ4iO6mE"

Two values is the correct result for an order covering both the apex and the wildcard. One value means your plugin replaced the record set instead of appending — see the troubleshooting section below.

After issuance, confirm the certificate actually carries both names:

openssl x509 -in /etc/letsencrypt/live/example.com/cert.pem -noout -text \
  | grep -A1 'Subject Alternative Name'
openssl x509 -in /etc/letsencrypt/live/example.com/cert.pem -noout -enddate
X509v3 Subject Alternative Name:
    DNS:*.example.com, DNS:example.com
notAfter=Oct 26 09:14:00 2026 GMT

Finally, rehearse renewal without touching your issuance quota. The dry run drives the whole DNS-01 path against the staging environment and cleans up after itself:

certbot renew --dry-run

Troubleshooting

The CA polled before the record propagated

The order fails within seconds with DNS problem: NXDOMAIN looking up TXT for _acme-challenge.example.com. The client wrote the record and told the CA it was ready before the authoritative servers agreed. Diagnose by querying every authoritative server directly rather than through a recursive resolver:

for ns in $(dig +short NS acme.example.net); do
  dig +short TXT example-com.acme.example.net "@${ns}"
done

If one server answers and another does not, the fix is polling rather than sleeping — every name server must answer before the challenge is marked ready. If none answer, the write itself failed and the plugin swallowed the API error.

Stale TXT from a previous run

A record left behind by an interrupted order stays in the set, and the CA sees a value that hashes to nothing it issued. Some CAs tolerate extra values at the name; others do not, and either way the set grows on every run until you hit the provider’s record-count limit. Clean up explicitly:

dig +short TXT example-com.acme.example.net | wc -l

Anything above two for a two-name order is residue. Make the cleanup hook idempotent — --manual-cleanup-hook in certbot, the --dns.disable-cp companion path in lego — and prefer deleting by record id rather than by matching value.

Two orders racing on the same name

Two hosts renewing the same wildcard at the same moment will each write a TXT, then each delete the other’s during cleanup, and both fail. This is the failure the RandomizedDelaySec above is protecting against, but jitter only reduces the odds. The durable fix is a lock: run issuance from one host and distribute the result, or wrap the renewal in a mutual exclusion primitive your fleet already trusts.

flock -n /var/lock/acme-renew.lock certbot renew --quiet || echo "another renewal holds the lock"

Provider API rate limits

Symptoms are HTTP 429 from the DNS API in the client log, not an ACME error at all. Cloudflare allows 1,200 API calls per five minutes per user token; Route 53 throttles ChangeResourceRecordSets at five requests per second per account. Renewing 200 certificates from one account in one minute will hit both. Stagger the timer, batch names into fewer certificates, and give each environment its own token so one team’s burst does not throttle another’s.

CAA forbids the CA you chose

The ACME error is explicit: CAA record for example.com prevents issuance. A wildcard order is checked against issuewild first, so a zone that publishes issue "letsencrypt.org" and an issuewild ";" will pass a plain certificate and reject the wildcard from the same CA. Read the effective set before blaming the client:

dig +short CAA example.com
dig +short CAA '*.example.com'

The full diagnostic path for this branch is in the companion guide on troubleshooting CAA and DNS-01 validation failures.

Split-horizon DNS answers the CA differently

If your resolvers return an internal view of example.com and the public authoritative servers return another, your own verification passes while the CA sees nothing. This is common when an internal resolver holds a stub zone for the apex and does not carry the _acme-challenge delegation. Always verify from outside your own resolver path:

dig +short TXT _acme-challenge.example.com @9.9.9.9
dig +trace TXT _acme-challenge.example.com | tail -20

The +trace walk shows which name servers the public delegation actually reaches. Where DNSSEC is in play, a broken chain produces SERVFAIL instead of an empty answer — that path is covered in debugging DNSSEC validation failures.

Frequently Asked Questions

Can I get a wildcard certificate without DNS-01? No. A wildcard identifier has no host to connect to, so http-01 and tls-alpn-01 cannot carry the proof, and every public CA rejects them for wildcard orders. DNS-01 is the only option, which is why the credential scoping in this guide matters so much.

Does the TXT record need to stay published after issuance? No. The CA reads it once during validation and never looks again, so the client deletes it in its cleanup step. Leaving it in place is harmless for correctness but accumulates dead values that eventually confuse the next run.

Why does my order need two TXT records at the same name? Because an order covering both example.com and *.example.com creates two authorizations, and both are answered at _acme-challenge.example.com with different tokens. The record set must hold both values at once; a plugin that overwrites rather than appends will fail one of them.

What TTL should the challenge record use? Sixty seconds or less, and the delegation zone’s SOA minimum should match so a cached NXDOMAIN expires quickly. Higher values do not make validation more reliable — they only lengthen the window in which a resolver serves a stale or negative answer. The same reasoning behind TTL strategy for fast-moving records applies here.

Is the delegation CNAME a security risk in itself? It is the opposite: it moves the only writable record out of the zone that carries your production traffic. Someone who compromises the certificate host can rewrite records in a zone that serves nothing, and cannot touch your A, MX, or TXT records. Keep the delegation zone under separate credentials and audit it like any other zone.

Back to CAA Records & Certificate Automation