Reconciler Provider API / Reconciliation

Stale ACME challenge TXT record blocks renewal

A certificate renewal fails with an error like "incorrect TXT record" or "domain: unauthorized," even though your ACME client just published a fresh challenge. The usual cause is not a broken renewal at all. It is an old _acme-challenge TXT record from a prior failed attempt that never got cleaned up, still sitting in the zone, still a valid answer to anyone who asks. Here is why that happens and a script that finds the leftover and removes it.

Python and Node.js Cloudflare DNS API Safe by default (dry run)
Two server racks
Photo by Eric Stoynov on Unsplash
The short answer

ACME DNS-01 validation works by publishing a one-time token in a TXT record at _acme-challenge.example.com, letting the certificate authority read it, then deleting it. If the cleanup step fails, crashes, or gets skipped, that old token stays in the zone. On the next renewal, the record set has more than one value, and if the resolver returns the stale one, or nameservers disagree, validation fails. Run dig +short TXT _acme-challenge.example.com, delete every value that is not the token your client just published, and add a small script that clears out anything older than about an hour so this cannot pile up again. Full code, tests, and a dry run guard are below.

The problem in plain words

DNS-01 validation is a short conversation. Your ACME client, such as certbot, asks the certificate authority for a challenge. It publishes the answer as a TXT record at _acme-challenge.example.com. The certificate authority looks up that name, checks the value matches what it expects, and issues the certificate. Afterward, the client is supposed to delete that TXT record, because its job is done.

The trouble is that last step, the delete, is a separate action from the publish, usually run by a hook script. If that hook fails, times out, or the whole renewal process gets killed partway through, the delete never happens. The record just sits there. Nothing about DNS forces it to expire on its own unless you set a very short TTL and even then a resolver may still hold onto the old answer for a while. Most DNS provider APIs also add records rather than replacing them in place, so an old token and a new token can both exist at the same name at once.

First renewal publishes old-token Cleanup hook fails old-token never deleted Zone now holds old-token, leftover Next renewal publishes new-token CA reads TXT two values found unauthorized renewal fails
The first renewal's cleanup hook fails and its token is never removed. The next renewal publishes a fresh token alongside it, and the certificate authority can see both, so validation fails.

Why it happens

This is reported often on both the Let's Encrypt community forum and DNS provider community forums, usually phrased as "it worked last time, now it just fails" right after a renewal that had some kind of hiccup. See the citations at the end for the exact threads.

The key insight

A DNS-01 challenge is only ever valid for a few minutes, the length of a single renewal attempt. Any _acme-challenge TXT record still standing an hour later is not part of anything currently happening. It is safe to treat it as leftover and delete it, because a real, in-progress validation would already be finished by then.

The fix, as a flow

List every TXT record at _acme-challenge.example.com through the DNS provider's API, check each one's age, and delete anything older than a short timeout, such as one hour, well past how long a real challenge ever takes. If a validation is currently in flight, its token is the one that is very recent and matches what the ACME client just published, so it is left alone. Everything else gets cleared before the CA looks at the record.

List TXT records at _acme-challenge Read each record's age Older than the timeout, or wrong token? Stale record? yes no, keep it Delete record via Cloudflare API
Every TXT record at the challenge name is checked by age and by token. Anything that is too old, or does not match the token currently in flight, is stale and gets deleted.

How to fix it

1

Look at what is actually published right now

Query the challenge name directly and see how many values come back. A healthy state during a renewal shows exactly one TXT value, matching the token the ACME client just requested. Two or more values, or one that does not match what the client just tried to set, points at a stale record.

Terminal
check-txt.sh
dig +short TXT _acme-challenge.example.com

# a bad result looks like this: two values, one clearly older
# "old-token-from-a-failed-run-weeks-ago"
# "fresh-token-the-client-just-published"

# check every authoritative nameserver, not just one cache
dig NS example.com +short
dig @ns1.example.com TXT _acme-challenge.example.com +short
dig @ns2.example.com TXT _acme-challenge.example.com +short

# cross-check against a public resolver too
dig @8.8.8.8 TXT _acme-challenge.example.com +short
2

Read the exact error from the ACME client

The certbot or ACME client log usually names the leftover value directly. Look for a line naming the value the CA found, which is the old token, next to the value your client just published.

Terminal
certbot-log-excerpt
Incorrect TXT record "old-token-from-a-failed-run" found at
_acme-challenge.example.com

Domain: example.com
Type:   unauthorized
Detail: ... did not receive a certificate ...
3

List every record at the challenge name via the DNS provider API

If you manage the zone through Cloudflare, list the TXT records at that exact name and look at the modified_on timestamp on each one. A bad result is more than one record, or a record whose timestamp predates your current renewal attempt by more than a few minutes.

Terminal
cloudflare-list.sh
curl -s -H "Authorization: Bearer $CF_TOKEN" \
  "https://api.cloudflare.com/client/v4/zones/$ZONE_ID/dns_records?type=TXT&name=_acme-challenge.example.com" \
  | jq '.result[] | {id, content, modified_on}'

# a bad result is more than one item in result, or a modified_on
# that is minutes or hours older than when your client just published
4

Delete the stale record, keep only the current one

Delete every TXT record at that name whose value does not match the token your ACME client just generated. In a manual or registrar UI, open the zone editor, find the _acme-challenge TXT records, and remove the ones that are not current. Through the Cloudflare API, delete by record ID.

Terminal
cloudflare-delete.sh
# delete each stale record by its id, one call per record
curl -s -X DELETE \
  -H "Authorization: Bearer $CF_TOKEN" \
  "https://api.cloudflare.com/client/v4/zones/$ZONE_ID/dns_records/$RECORD_ID"

# the final TXT set for _acme-challenge.example.com should hold
# only the current challenge value, or nothing at all once the
# ACME client's own cleanup step runs after issuance
5

Fix the root cause in the ACME client too

Make sure the cleanup hook actually runs even when validation itself fails, by wrapping it in a finally block or a shell trap so it always fires. That alone will not catch every crash, so pair it with the scheduled reconciliation script below.

Terminal
cleanup-hook-wrapper.sh
#!/usr/bin/env bash
# wrap certbot's own hook call so cleanup always runs,
# even if validation itself failed partway through
set -e
trap 'bash /etc/letsencrypt/cleanup-hook.sh' EXIT

certbot renew \
  --manual \
  --preferred-challenges dns \
  --manual-auth-hook /etc/letsencrypt/auth-hook.sh \
  --manual-cleanup-hook /etc/letsencrypt/cleanup-hook.sh

How to check it worked

Re-query the record, confirm it is a single clean value or empty, then re-run the renewal for real and watch it finish without the unauthorized error.

Terminal
verify.sh
# should return exactly one value, or nothing at all
dig +short TXT _acme-challenge.example.com

# confirm every authoritative nameserver agrees
for ns in $(dig NS example.com +short); do
  echo "$ns:"
  dig @"$ns" TXT _acme-challenge.example.com +short
done

# dry run the renewal first, then run it for real
certbot renew --cert-name example.com --dry-run
certbot renew --cert-name example.com

# confirm the new certificate is live with a fresh expiry date
openssl s_client -connect example.com:443 -servername example.com \
  </dev/null 2>/dev/null | openssl x509 -noout -dates

A good result is a single matching TXT value (or none) that every nameserver agrees on, a renewal that completes with no "incorrect TXT record" or "unauthorized" error, and a certificate whose notAfter date is roughly ninety days out, which is the Let's Encrypt default.

The full code

Here is the complete checker and repair script in one file for each language. It reads the live TXT records at the challenge name, flags anything older than a configurable timeout or that does not match the token currently in flight, and, only when you turn dry run off, deletes those stale records through the Cloudflare API.

View this code on GitHub Full runnable folder with tests in the dns-fixes repo.

stale_acme_challenge_record.py
"""Detect a stale _acme-challenge TXT record left behind by a failed
ACME DNS-01 renewal, and optionally repair the zone via Cloudflare by
deleting the stale record(s).

Safe by default. Set DRY_RUN=false to let it write.

Env vars:
  DNS_DOMAIN               the domain to check, e.g. "example.com"
  CURRENT_CHALLENGE_TOKEN  the token the ACME client is currently
                           trying to validate, if any (leave unset or
                           empty when no validation is in flight)
  STALE_TIMEOUT_SECONDS    default 3600; age past which a record is
                           always considered stale
  CLOUDFLARE_API_TOKEN     Cloudflare API token (only needed for repair)
  CLOUDFLARE_ZONE_ID       Cloudflare zone id (only needed for repair)
  DRY_RUN                  default "true"; set to "false" to actually write
"""
import os
import time
import logging

logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("stale_acme_challenge_record")

DNS_DOMAIN = os.environ.get("DNS_DOMAIN", "example.com")
CURRENT_CHALLENGE_TOKEN = os.environ.get("CURRENT_CHALLENGE_TOKEN") or None
STALE_TIMEOUT_SECONDS = int(os.environ.get("STALE_TIMEOUT_SECONDS", "3600"))
CLOUDFLARE_API_TOKEN = os.environ.get("CLOUDFLARE_API_TOKEN", "")
CLOUDFLARE_ZONE_ID = os.environ.get("CLOUDFLARE_ZONE_ID", "")
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"

CF_API = "https://api.cloudflare.com/client/v4"
GRACE_SECONDS = 300


def find_stale_challenge_records(records, current_token, now_ts, timeout_s=3600):
    """Pure decision function. No I/O.

    records: list of TXT record dicts for _acme-challenge.<domain>, each
             with 'id', 'content', and 'modified_on' as epoch seconds.
    current_token: the token the ACME client is currently trying to
             validate, or None if no validation is in flight.
    now_ts: the current time as epoch seconds.
    timeout_s: age in seconds past which a record is always stale,
             regardless of its content, since a real challenge never
             takes anywhere near this long.

    Returns the list of record ids that are stale: any record older
    than timeout_s, or, when current_token is not None, any record
    whose content differs from current_token once it is older than a
    short grace period (so a record published moments ago as part of
    the in-flight challenge is never flagged before it has had a
    chance to be read by the CA).
    """
    stale_ids = []
    for record in records:
        age = now_ts - record["modified_on"]
        if age > timeout_s:
            stale_ids.append(record["id"])
            continue
        if current_token is not None and record["content"] != current_token and age > GRACE_SECONDS:
            stale_ids.append(record["id"])
    return stale_ids


def list_challenge_records(domain):
    """List every TXT record at _acme-challenge.<domain> via Cloudflare."""
    import requests

    headers = {"Authorization": f"Bearer {CLOUDFLARE_API_TOKEN}"}
    params = {"type": "TXT", "name": f"_acme-challenge.{domain}", "per_page": 100}
    r = requests.get(
        f"{CF_API}/zones/{CLOUDFLARE_ZONE_ID}/dns_records",
        headers=headers, params=params, timeout=30,
    )
    r.raise_for_status()
    return r.json()["result"]


def delete_record(record_id):
    import requests

    headers = {"Authorization": f"Bearer {CLOUDFLARE_API_TOKEN}"}
    url = f"{CF_API}/zones/{CLOUDFLARE_ZONE_ID}/dns_records/{record_id}"
    if DRY_RUN:
        log.info("[dry run] would delete record %s", record_id)
        return
    requests.delete(url, headers=headers, timeout=30).raise_for_status()
    log.info("Deleted record %s", record_id)


def run():
    records = list_challenge_records(DNS_DOMAIN)
    if not records:
        log.info("No _acme-challenge TXT records found for %s.", DNS_DOMAIN)
        return

    now_ts = int(time.time())
    stale_ids = find_stale_challenge_records(records, CURRENT_CHALLENGE_TOKEN, now_ts, STALE_TIMEOUT_SECONDS)
    if not stale_ids:
        log.info("All %d TXT record(s) at _acme-challenge.%s look current.", len(records), DNS_DOMAIN)
        return

    for record_id in stale_ids:
        log.warning("Stale _acme-challenge TXT record for %s: id %s", DNS_DOMAIN, record_id)

    if not (CLOUDFLARE_API_TOKEN and CLOUDFLARE_ZONE_ID):
        log.warning("No Cloudflare credentials set. Not repairing, only reporting.")
        return

    for record_id in stale_ids:
        delete_record(record_id)
    log.info("Done. %d stale record(s) %s.", len(stale_ids), "would be removed" if DRY_RUN else "removed")


if __name__ == "__main__":
    run()
stale-acme-challenge-record.js
/**
 * Detect a stale _acme-challenge TXT record left behind by a failed
 * ACME DNS-01 renewal, and optionally repair the zone via Cloudflare
 * by deleting the stale record(s).
 *
 * Safe by default. Set DRY_RUN=false to let it write.
 *
 * Env vars:
 *   DNS_DOMAIN               the domain to check, e.g. "example.com"
 *   CURRENT_CHALLENGE_TOKEN  the token the ACME client is currently
 *                            trying to validate, if any (leave unset
 *                            or empty when no validation is in flight)
 *   STALE_TIMEOUT_SECONDS    default 3600; age past which a record is
 *                            always considered stale
 *   CLOUDFLARE_API_TOKEN     Cloudflare API token (only needed for repair)
 *   CLOUDFLARE_ZONE_ID       Cloudflare zone id (only needed for repair)
 *   DRY_RUN                  default "true"; set to "false" to actually write
 */
import { pathToFileURL } from "node:url";

const DNS_DOMAIN = process.env.DNS_DOMAIN || "example.com";
const CURRENT_CHALLENGE_TOKEN = process.env.CURRENT_CHALLENGE_TOKEN || null;
const STALE_TIMEOUT_SECONDS = Number(process.env.STALE_TIMEOUT_SECONDS || 3600);
const CLOUDFLARE_API_TOKEN = process.env.CLOUDFLARE_API_TOKEN || "";
const CLOUDFLARE_ZONE_ID = process.env.CLOUDFLARE_ZONE_ID || "";
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";

const CF_API = "https://api.cloudflare.com/client/v4";
const GRACE_SECONDS = 300;

export function findStaleChallengeRecords(records, currentToken, nowTs, timeoutS = 3600) {
  // Pure decision function. No I/O.
  //
  // records: list of TXT record objects for _acme-challenge.<domain>,
  // each with 'id', 'content', and 'modified_on' as epoch seconds.
  // currentToken: the token the ACME client is currently trying to
  // validate, or null if no validation is in flight.
  // nowTs: the current time as epoch seconds.
  // timeoutS: age in seconds past which a record is always stale,
  // regardless of its content, since a real challenge never takes
  // anywhere near this long.
  //
  // Returns the list of record ids that are stale: any record older
  // than timeoutS, or, when currentToken is not null, any record
  // whose content differs from currentToken once it is older than a
  // short grace period.
  const staleIds = [];
  for (const record of records) {
    const age = nowTs - record.modified_on;
    if (age > timeoutS) {
      staleIds.push(record.id);
      continue;
    }
    if (currentToken !== null && record.content !== currentToken && age > GRACE_SECONDS) {
      staleIds.push(record.id);
    }
  }
  return staleIds;
}

async function listChallengeRecords(domain) {
  const headers = { Authorization: `Bearer ${CLOUDFLARE_API_TOKEN}` };
  const params = new URLSearchParams({ type: "TXT", name: `_acme-challenge.${domain}`, per_page: "100" });
  const res = await fetch(
    `${CF_API}/zones/${CLOUDFLARE_ZONE_ID}/dns_records?${params.toString()}`,
    { headers },
  );
  if (!res.ok) throw new Error(`Cloudflare list returned ${res.status}`);
  const body = await res.json();
  return body.result;
}

async function deleteRecord(recordId) {
  const headers = { Authorization: `Bearer ${CLOUDFLARE_API_TOKEN}` };
  if (DRY_RUN) {
    console.log(`[dry run] would delete record ${recordId}`);
    return;
  }
  const res = await fetch(`${CF_API}/zones/${CLOUDFLARE_ZONE_ID}/dns_records/${recordId}`, {
    method: "DELETE",
    headers,
  });
  if (!res.ok) throw new Error(`Cloudflare delete returned ${res.status}`);
  console.log(`Deleted record ${recordId}`);
}

async function run() {
  const records = await listChallengeRecords(DNS_DOMAIN);
  if (records.length === 0) {
    console.log(`No _acme-challenge TXT records found for ${DNS_DOMAIN}.`);
    return;
  }

  const nowTs = Math.floor(Date.now() / 1000);
  const staleIds = findStaleChallengeRecords(records, CURRENT_CHALLENGE_TOKEN, nowTs, STALE_TIMEOUT_SECONDS);
  if (staleIds.length === 0) {
    console.log(`All ${records.length} TXT record(s) at _acme-challenge.${DNS_DOMAIN} look current.`);
    return;
  }

  for (const recordId of staleIds) {
    console.warn(`Stale _acme-challenge TXT record for ${DNS_DOMAIN}: id ${recordId}`);
  }

  if (!(CLOUDFLARE_API_TOKEN && CLOUDFLARE_ZONE_ID)) {
    console.warn("No Cloudflare credentials set. Not repairing, only reporting.");
    return;
  }

  for (const recordId of staleIds) {
    await deleteRecord(recordId);
  }
  console.log(`Done. ${staleIds.length} stale record(s) ${DRY_RUN ? "would be removed" : "removed"}.`);
}

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 decision rule is the part most worth testing, because it decides which records are safe to delete. Because find_stale_challenge_records is pure, the test needs no network and no DNS provider. It just feeds in plain dicts and timestamps and checks which ids come back as stale.

test_stale_challenge.py
from stale_acme_challenge_record import find_stale_challenge_records

NOW = 1_700_000_000


def record(id_, content, age_seconds):
    return {"id": id_, "content": content, "modified_on": NOW - age_seconds}


def test_no_stale_when_single_fresh_record_matches_token():
    records = [record("r1", "fresh-token", 30)]
    assert find_stale_challenge_records(records, "fresh-token", NOW) == []


def test_flags_record_older_than_timeout_regardless_of_content():
    records = [record("r1", "old-token", 7200)]
    assert find_stale_challenge_records(records, None, NOW, timeout_s=3600) == ["r1"]


def test_flags_mismatched_token_past_grace_period():
    records = [record("r1", "old-token", 600)]
    assert find_stale_challenge_records(records, "fresh-token", NOW) == ["r1"]


def test_does_not_flag_mismatched_token_within_grace_period():
    records = [record("r1", "old-token", 60)]
    assert find_stale_challenge_records(records, "fresh-token", NOW) == []


def test_mixed_set_flags_only_the_stale_one():
    records = [record("r1", "fresh-token", 30), record("r2", "old-token", 10800)]
    assert find_stale_challenge_records(records, "fresh-token", NOW) == ["r2"]


def test_no_current_token_only_uses_timeout():
    records = [record("r1", "anything", 300)]
    assert find_stale_challenge_records(records, None, NOW, timeout_s=3600) == []


def test_empty_records_returns_empty_list():
    assert find_stale_challenge_records([], "fresh-token", NOW) == []
stale-acme-challenge-record.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { findStaleChallengeRecords } from "./stale-acme-challenge-record.js";

const NOW = 1_700_000_000;

function record(id, content, ageSeconds) {
  return { id, content, modified_on: NOW - ageSeconds };
}

test("no stale when single fresh record matches token", () => {
  const records = [record("r1", "fresh-token", 30)];
  assert.deepEqual(findStaleChallengeRecords(records, "fresh-token", NOW), []);
});

test("flags record older than timeout regardless of content", () => {
  const records = [record("r1", "old-token", 7200)];
  assert.deepEqual(findStaleChallengeRecords(records, null, NOW, 3600), ["r1"]);
});

test("flags mismatched token past grace period", () => {
  const records = [record("r1", "old-token", 600)];
  assert.deepEqual(findStaleChallengeRecords(records, "fresh-token", NOW), ["r1"]);
});

test("does not flag mismatched token within grace period", () => {
  const records = [record("r1", "old-token", 60)];
  assert.deepEqual(findStaleChallengeRecords(records, "fresh-token", NOW), []);
});

test("mixed set flags only the stale one", () => {
  const records = [record("r1", "fresh-token", 30), record("r2", "old-token", 10800)];
  assert.deepEqual(findStaleChallengeRecords(records, "fresh-token", NOW), ["r2"]);
});

test("no current token only uses timeout", () => {
  const records = [record("r1", "anything", 300)];
  assert.deepEqual(findStaleChallengeRecords(records, null, NOW, 3600), []);
});

test("empty records returns empty list", () => {
  assert.deepEqual(findStaleChallengeRecords([], "fresh-token", NOW), []);
});

Case studies

Wildcard certificate

The renewal that failed once and then failed forever

A team ran a wildcard certificate renewal through a shared CI runner. One run got interrupted when the runner was killed for hitting a time limit, right after the challenge TXT record was published but before the cleanup hook ran. Every renewal after that failed with an "incorrect TXT record" error, because the old token was still sitting there next to each new one.

Nobody thought to check the DNS zone itself, since the error read like an ACME problem. A single dig +short TXT _acme-challenge.example.com showed two values, and deleting the old one fixed the very next renewal attempt.

Multi-domain certificate

Four subdomains, one broken cleanup script

A certificate covering four subdomains ran four separate DNS-01 challenges. A bug in a custom cleanup script meant it only ever cleaned up the first challenge name it was given and silently ignored the rest. For weeks, three of the four _acme-challenge names quietly accumulated a new leftover TXT record on every renewal.

Renewal still worked for a while, since the CA read the newest matching value most of the time, until nameserver disagreement finally made one of them serve a stale answer during validation. A scheduled script that deletes anything older than an hour would have caught this on day one instead of week three.

What good looks like

After the fix, dig +short TXT _acme-challenge.example.com returns exactly one value during a renewal, and nothing at all once the ACME client's own cleanup finishes. Every authoritative nameserver agrees. Renewal completes without an unauthorized or incorrect TXT record error, and the certificate's notAfter date rolls forward every time, on schedule, with no manual intervention.

FAQ

Why does an old _acme-challenge TXT record break certificate renewal?

DNS-01 validation checks the _acme-challenge TXT record for the exact token the certificate authority just issued. If a cleanup step failed or was skipped on an earlier attempt, the old token is still sitting there. The name now holds more than one value, and if the resolver returns the stale one first, or the provider disagrees across nameservers, validation fails with an unauthorized or incorrect TXT record error.

Is it safe to just delete every _acme-challenge TXT record I find?

It is safe once you confirm no validation is currently in flight, or once the record is older than a real challenge ever needs to be, typically a few minutes at most. A record older than about an hour is not part of an active renewal. Deleting it cannot break a validation that already finished or that has not started yet.

How do I stop this from happening again on future renewals?

Make sure your ACME client's cleanup hook actually runs even when validation fails, by wrapping it so cleanup always executes. On top of that, add a small scheduled script that lists every _acme-challenge TXT record, checks its age, and deletes anything older than about an hour, so an orphaned record never has the chance to sit there until the next renewal.

Related field notes

Citations

On the problem:

  1. Let's Encrypt Community Support: _acme-challenge Incorrect TXT record, old data. community.letsencrypt.org
  2. Cloudflare Community: extra ACME TXT records preventing renewal. community.cloudflare.com
  3. RFC 8555: Automatic Certificate Management Environment (ACME), section 8.4, DNS-01. datatracker.ietf.org/doc/html/rfc8555

On the solution:

  1. Cloudflare API docs: DNS Records, list. developers.cloudflare.com/api
  2. Cloudflare API docs: DNS Records, delete. developers.cloudflare.com/api
  3. Certbot User Guide: manual-auth-hook and manual-cleanup-hook. eff-certbot.readthedocs.io

Stuck on a tricky one?

If you have a DNS, domain, 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 renewal?

If this got your certificate renewing cleanly again, 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