Type something to search...
How Does the DNS-01 Challenge Work for Let's Encrypt SSL Certificates?

How Does the DNS-01 Challenge Work for Let's Encrypt SSL Certificates?

Before Let's Encrypt issues a certificate for your domain, it has to be convinced that you actually control that domain. The most common way to prove it is the HTTP-01 challenge: put a file on your web server and let the CA fetch it. That works for a single public web server, but it falls apart when you need a wildcard certificate, when the server isn't reachable from the internet, or when dozens of load-balanced machines share one name. The DNS-01 challenge solves all three by moving the proof into DNS: you publish a specific TXT record, and the CA looks it up.

This article explains what happens during a DNS-01 challenge at the protocol level, how the TXT value is computed, how to run it with certbot (manually, with hooks, and with a DNS provider plugin), and how to troubleshoot the errors you're most likely to hit. The general idea of proving control through a TXT record is covered in how to verify domain ownership with a TXT record; here we focus on the ACME-specific details.

Where DNS-01 Fits in ACME

Let's Encrypt uses the ACME protocol (Automatic Certificate Management Environment, RFC 8555). An ACME client such as certbot, acme.sh, or lego talks to the CA's API on your behalf. Issuing a certificate goes through these stages:

  1. Create an order. The client asks for a certificate covering one or more identifiers, such as example.com and *.example.com.
  2. Receive authorizations. For each identifier, the CA returns an authorization containing one or more challenges the client can choose from: http-01, dns-01, or tls-alpn-01.
  3. Complete a challenge. The client fulfils one challenge per identifier and tells the CA it's ready.
  4. Validation. The CA checks the challenge from multiple network vantage points.
  5. Finalize. Once every authorization is valid, the client sends a certificate signing request (CSR) and downloads the certificate.

The three challenge types differ in how they prove control:

ChallengeHow control is provenWildcardsNeeds inbound accessTypical use
HTTP-01File at http://domain/.well-known/acme-challenge/ on port 80NoYes, port 80Single public web server
TLS-ALPN-01Special certificate served on port 443NoYes, port 443Servers or proxies that can't use port 80
DNS-01TXT record at _acme-challenge.domainYesNoWildcards, internal hosts, many servers

DNS-01 is the only challenge type that can validate a wildcard name, because a wildcard isn't a single server you can fetch a file from.

What Happens During a DNS-01 Challenge

For a DNS-01 challenge, the CA gives the client a random token. The client then builds the TXT record value as follows:

  1. Key authorization. Concatenate the token, a period, and the base64url-encoded SHA-256 thumbprint of the ACME account's public key (RFC 7638 JWK thumbprint): token.thumbprint.
  2. Digest. Hash the key authorization with SHA-256.
  3. Encode. Base64url-encode the hash without padding. The result is always 43 characters.

The client publishes that value as a TXT record at the label _acme-challenge in front of the domain:

_acme-challenge.example.com.  60  IN  TXT  "gfj9Xq...Rg85nM"

Because the value depends on your account key, someone who observes the TXT record can't reuse it to get a certificate for their own account.

Here's the same computation in Python, using only the standard library, for an account key you already have in JWK form:

import base64
import hashlib
import json

def b64url(data: bytes) -> str:
    return base64.urlsafe_b64encode(data).rstrip(b"=").decode()

# Public part of an ACME account key (EC P-256), as a JWK
jwk = {
    "crv": "P-256",
    "kty": "EC",
    "x": "f83OJ3D2xF1Bg8vub9tLe1gHMzV76e8Tus9uPHvRVEU",
    "y": "x_FEzRu9m36HLN_tue659LNpXW6pCyStikYjKIWI5a0",
}

# RFC 7638 thumbprint: required members only, sorted keys, no whitespace
canonical = json.dumps({k: jwk[k] for k in ("crv", "kty", "x", "y")},
                       sort_keys=True, separators=(",", ":"))
thumbprint = b64url(hashlib.sha256(canonical.encode()).digest())

token = "evaGxfADs6pSRb2LAv9IZf17Dt3juxGJ-PCt92wr-oA"  # issued by the CA
key_authorization = f"{token}.{thumbprint}"
txt_value = b64url(hashlib.sha256(key_authorization.encode()).digest())

print("_acme-challenge TXT value:", txt_value)

The script canonicalizes the JWK as RFC 7638 requires, computes its thumbprint, builds the key authorization, and prints the 43-character TXT value. You never need to do this by hand — ACME clients handle it — but it shows exactly what the CA is checking.

Once the record is published, the client tells the CA to validate. Let's Encrypt queries DNS for the TXT record from several locations (multi-perspective validation, which is now required for publicly trusted CAs), resolving it from the root down to your authoritative nameservers rather than trusting someone else's cache. It also checks your CAA records to make sure the domain allows Let's Encrypt to issue — see what a CAA record is. If the expected value is present, the authorization becomes valid.

A few details matter in practice:

  • Multiple values at once. A certificate for both example.com and *.example.com needs two authorizations, and both use the same name, _acme-challenge.example.com. The record must contain both TXT values at the same time. Most clients handle this automatically.
  • CNAMEs are followed. If _acme-challenge.example.com is a CNAME, the CA follows it and checks the TXT record at the target. This enables delegated validation, covered below.
  • Old records are harmless but messy. Extra stale values don't cause failure as long as the correct one is present, but clients should clean up after themselves.

Using DNS-01 with certbot

Manual mode (for testing or one-off certificates)

The simplest way to see DNS-01 in action is certbot's manual mode:

sudo certbot certonly --manual --preferred-challenges dns \
  -d example.com -d "*.example.com"

Certbot prints a TXT value and asks you to create _acme-challenge.example.com with it, then waits. Because this request covers both the apex and the wildcard, it shows two values, one after the other; add both as separate TXT records at the same name. Before pressing Enter, confirm the records are visible at your authoritative nameservers:

dig TXT _acme-challenge.example.com +short @"$(dig NS example.com +short | head -n1)"

This finds one of the domain's authoritative nameservers and queries it directly, so you see what the CA will see instead of a cached answer.

Manual mode has a big limitation: it can't renew automatically, because certbot has no way to create the TXT record next time. Use it only for testing, or add hooks.

Manual mode with hooks

Certbot's --manual-auth-hook and --manual-cleanup-hook run scripts that create and delete the record. Certbot passes the domain and value in the environment variables CERTBOT_DOMAIN and CERTBOT_VALIDATION. Here's an auth hook using the Cloudflare API:

#!/usr/bin/env bash
# /etc/letsencrypt/hooks/cloudflare-auth.sh
set -euo pipefail

: "${CF_API_TOKEN:?set CF_API_TOKEN}"
: "${CF_ZONE_ID:?set CF_ZONE_ID}"

curl -sf -X POST "https://api.cloudflare.com/client/v4/zones/${CF_ZONE_ID}/dns_records" \
  -H "Authorization: Bearer ${CF_API_TOKEN}" \
  -H "Content-Type: application/json" \
  --data "{\"type\":\"TXT\",\"name\":\"_acme-challenge.${CERTBOT_DOMAIN}\",\"content\":\"${CERTBOT_VALIDATION}\",\"ttl\":60}" \
  > /dev/null

# Give the authoritative nameservers time to publish the record
sleep 30

And a matching cleanup hook that deletes the record with that value:

#!/usr/bin/env bash
# /etc/letsencrypt/hooks/cloudflare-cleanup.sh
set -euo pipefail

ids=$(curl -sf "https://api.cloudflare.com/client/v4/zones/${CF_ZONE_ID}/dns_records?type=TXT&name=_acme-challenge.${CERTBOT_DOMAIN}" \
  -H "Authorization: Bearer ${CF_API_TOKEN}" \
  | jq -r --arg v "$CERTBOT_VALIDATION" '.result[] | select(.content == $v or .content == ("\"" + $v + "\"")) | .id')

for id in $ids; do
  curl -sf -X DELETE "https://api.cloudflare.com/client/v4/zones/${CF_ZONE_ID}/dns_records/${id}" \
    -H "Authorization: Bearer ${CF_API_TOKEN}" > /dev/null
done

Make both executable, export CF_API_TOKEN and CF_ZONE_ID, and run:

sudo -E certbot certonly --manual --preferred-challenges dns \
  --manual-auth-hook /etc/letsencrypt/hooks/cloudflare-auth.sh \
  --manual-cleanup-hook /etc/letsencrypt/hooks/cloudflare-cleanup.sh \
  -d example.com -d "*.example.com"

Certbot stores the hook paths in the renewal configuration, so certbot renew can repeat the process unattended (the environment variables must also be available to the renewal job). Hooks are the right approach for DNS providers that don't have a certbot plugin.

Using a DNS plugin (recommended)

For popular DNS providers, a certbot plugin does all of this for you. With Cloudflare, install the plugin — via pip in the same environment as certbot, or as a snap:

# pip-based install
pip install certbot certbot-dns-cloudflare

# or, with the snap packages
sudo snap install --classic certbot
sudo snap set certbot trust-plugin-with-root=ok
sudo snap install certbot-dns-cloudflare

Create a credentials file with an API token scoped to Zone: DNS: Edit for the zone:

# /root/.secrets/certbot/cloudflare.ini
dns_cloudflare_api_token = 0123456789abcdef0123456789abcdef01234567

Lock down its permissions, then request the certificate:

sudo chmod 600 /root/.secrets/certbot/cloudflare.ini

sudo certbot certonly \
  --dns-cloudflare \
  --dns-cloudflare-credentials /root/.secrets/certbot/cloudflare.ini \
  --dns-cloudflare-propagation-seconds 30 \
  -d example.com -d "*.example.com"

The plugin creates the TXT records through Cloudflare's API, waits the given number of seconds for propagation, asks Let's Encrypt to validate, and removes the records afterwards. Similar plugins exist for Route 53 (certbot-dns-route53, using --dns-route53 and standard AWS credentials), Google Cloud DNS, DigitalOcean, and others.

Test that renewal will work without waiting months:

sudo certbot renew --dry-run

--dry-run runs the full DNS-01 flow against Let's Encrypt's staging environment without saving new certificates. Run it after any change to credentials or DNS hosting. If you host DNS on Cloudflare, the zone setup itself is covered in how to set up Cloudflare DNS for your website.

Other ACME clients

The same flow is available in other clients. With acme.sh and its Cloudflare DNS API integration:

export CF_Token="0123456789abcdef0123456789abcdef01234567"
acme.sh --issue --dns dns_cf -d example.com -d "*.example.com"

acme.sh reads the token from CF_Token, creates and removes the TXT records, and saves the credentials for future renewals.

Delegating _acme-challenge with a CNAME

Giving every server an API token that can edit your entire production zone is risky. Because the CA follows CNAMEs, you can delegate only the challenge name to a separate zone:

; In the main example.com zone (set once, by hand)
_acme-challenge.example.com.  3600  IN  CNAME  _acme-challenge.example.com.acme.example.net.

Now the ACME client only needs write access to acme.example.net, a zone with nothing else in it. Tools such as acme-dns are built for this pattern, running a tiny authoritative DNS server whose sole job is serving challenge TXT records. If credentials leak, an attacker can obtain certificates (still a real risk) but can't redirect your website or mail.

Why Use DNS-01 Instead of HTTP-01

  1. Wildcard certificates. DNS-01 is the only way to get *.example.com from Let's Encrypt.
  2. Internal and private servers. Hosts that aren't reachable from the internet — intranet apps, home labs, staging environments — can still get publicly trusted certificates, provided their names exist in public DNS. If you run internal names through split DNS, see what split-horizon DNS is.
  3. Many servers, one name. With HTTP-01 behind a load balancer, the validation request might hit a server that doesn't have the token. DNS-01 sidesteps that.
  4. No inbound ports. You don't need port 80 open.

The trade-off is credential management: the machine requesting certificates needs a way to write to DNS. Shorter certificate lifetimes make this more important — Let's Encrypt's standard certificates have long lasted 90 days, and Let's Encrypt has announced a phased move to shorter default lifetimes, with short-lived certificates already offered as an option. Manual DNS-01 doesn't scale; automation does.

Troubleshooting DNS-01 Errors

  1. DNS problem: NXDOMAIN looking up TXT. The CA reports NXDOMAIN for _acme-challenge.example.com, meaning the record doesn't exist where it looked. Check the host field (watch for a doubled domain), and confirm you edited the DNS provider that's actually authoritative.
  2. Incorrect TXT record found. A record exists at _acme-challenge.example.com but not with the expected value — often an old value, or only one of two values for an apex-plus-wildcard request.
  3. Propagation timing. Some DNS hosts take minutes to publish changes to all their nameservers. Increase --dns-cloudflare-propagation-seconds (or the equivalent) or the sleep in your hook. Background on why changes aren't instant is in what a DNS propagation delay is.
  4. SERVFAIL during lookup. Often caused by broken DNSSEC on the zone; the CA's resolvers validate DNSSEC and refuse unsigned or mis-signed answers. Fix the DS record or signatures — see what DNSSEC is.
  5. CAA forbids issuance. If your CAA records don't list letsencrypt.org, validation succeeds but issuance is refused.
  6. Rate limits. Repeated failures can hit Let's Encrypt's failed-validation limits. Test with the staging environment (--dry-run or --test-cert) while debugging.
  7. Unreachable or inconsistent nameservers. If one of your authoritative nameservers is out of sync, some vantage points see the record and others don't, and multi-perspective validation fails. Query every nameserver listed in dig NS.

A quick check across all authoritative nameservers:

for ns in $(dig NS example.com +short); do
  echo "== $ns =="
  dig TXT _acme-challenge.example.com @"$ns" +short
done

Every nameserver should return the same set of values.


DNS-01 Challenge FAQ

It is the TXT record an ACME client publishes at _acme-challenge in front of your domain during a DNS-01 challenge. Its value is a base64url-encoded SHA-256 hash of the challenge token combined with your account key thumbprint.

Yes. Let's Encrypt only issues wildcard certificates through the DNS-01 challenge, because there's no single server to check for a wildcard name.

You can, but it's better to remove it. Each challenge uses a new value, so old records serve no purpose. ACME clients and plugins normally clean up automatically.

Manual mode relies on you creating the TXT record by hand. Without an auth hook or a DNS plugin, certbot can't publish the new value at renewal time, so renewal fails.

Long enough for all your authoritative nameservers to serve it, typically between a few seconds and a couple of minutes. Let's Encrypt queries authoritative servers rather than caches, so resolver TTLs matter less than your provider's publishing speed.

Yes. Only the DNS record needs to be public. The server itself never has to accept connections from Let's Encrypt, which makes DNS-01 ideal for internal services.

It is a risk, because anyone with those credentials can change DNS records. Use tokens scoped to a single zone with DNS edit permissions only, restrict file permissions, or delegate _acme-challenge to a separate zone with a CNAME.

Yes. If _acme-challenge is a CNAME, Let's Encrypt follows it and checks the TXT record at the target name, which is how delegated validation works.

HTTP-01 proves control by serving a file over port 80 on the domain. DNS-01 proves control by publishing a TXT record. DNS-01 supports wildcards and doesn't need inbound access, but requires the ability to update DNS.

Conclusion

The DNS-01 challenge proves control of a domain by publishing a single TXT record at _acme-challenge, whose value is derived from the CA's token and your ACME account key. The CA looks it up from multiple vantage points, checks CAA, and issues the certificate once every name in the order is validated. That simple mechanism unlocks wildcard certificates, certificates for internal hosts, and painless issuance behind load balancers.

In practice, use a certbot DNS plugin (or hooks, for providers without one), scope your API credentials tightly or delegate _acme-challenge to a dedicated zone, and run certbot renew --dry-run after any change. With automation in place, shorter certificate lifetimes stop being a chore and DNS-01 becomes the most flexible way to keep every hostname on HTTPS.

Here are some useful references for the DNS-01 challenge:

  1. RFC 8555: Automatic Certificate Management Environment (ACME) — the ACME specification, including the DNS-01 challenge definition in section 8.4.
  2. Let's Encrypt: Challenge Types — Let's Encrypt's explanation of HTTP-01, DNS-01, and TLS-ALPN-01, with pros and cons of each.
  3. Certbot Documentation: Certbot User Guide — official documentation for manual mode, hooks, DNS plugins, and renewal.
  4. certbot-dns-cloudflare: Plugin documentation — credentials format and options for the Cloudflare DNS plugin.
  5. RFC 7638: JSON Web Key (JWK) Thumbprint — defines the account key thumbprint used in the key authorization.
  6. Let's Encrypt: Rate Limits — current issuance and failed-validation limits to keep in mind while testing.
Tags :
Share :

Related Posts

What Is the Difference Between Authoritative and Recursive DNS Servers?

What Is the Difference Between Authoritative and Recursive DNS Servers?

When someone says "the DNS server," they could mean two completely different machines doing two completely different jobs. One kind of server holds t

Continue Reading
Can DNS settings affect website speed?

Can DNS settings affect website speed?

Yes, DNS settings can significantly affect the speed at which a website loads for its users. DNS, or Domain Name System, is often likened to the inte

Continue Reading
Can You Use a CNAME Record on the Root Domain?

Can You Use a CNAME Record on the Root Domain?

It is one of the most common DNS questions there is. Your hosting platform says "add a CNAME pointing to myapp.example-cdn.net," it works perfectly

Continue Reading