Diagnostic DNSSEC
Expired RRSIG signatures break validation
A domain that has been signed and working for months suddenly starts failing for anyone using a validating resolver. Nothing in the zone changed. No record was edited. The problem is time itself: every RRSIG signature carries an expiration date, and once that date passes, the signature is no longer trusted, even though the record underneath it is perfectly correct. Here is why that happens and how to get the zone signing again.
Every RRSIG record has an expiration timestamp baked in when it is created, usually a week or two out. A DNS host is supposed to quietly re-sign the zone well before that date arrives and hand out a fresh RRSIG with a new expiration window. When that re-signing stops, whether from a paused zone, a broken automation job, or a signer that silently failed, the old RRSIG keeps being served past its expiration date. A validating resolver checks that date before anything else, sees it has passed, and returns SERVFAIL, even though the A, MX, or other record it is protecting never changed. The fix is to check the RRSIG expiration with dig, confirm the record itself is still correct, and trigger a re-sign at the DNS host so a new RRSIG with a future expiration date gets published. Full code, tests, and a dry run guard are below.
The problem in plain words
DNSSEC does not just check that a record is correct, it checks that the signature over that record is still valid right now. Part of "valid right now" is a plain expiration date sitting inside every RRSIG record, usually somewhere between a few days and a few weeks in the future. A healthy DNS host re-signs each record well before that date, swapping in a new RRSIG with a new expiration window, over and over, forever, without anyone noticing.
If that re-signing loop stops for any reason, the old RRSIG does not disappear. It keeps sitting there, still attached to the record, still being handed out on every query, right up until and past its expiration date. A resolver that does not check DNSSEC will not notice a thing, since it never looks at the RRSIG at all. A resolver that does check DNSSEC reads the expiration field, compares it to the current time, sees the signature is stale, and stops trusting the answer completely. It returns SERVFAIL rather than accept data it can no longer verify, even though the A record, the MX record, or whatever else is being protected has not changed one bit.
Why it happens
- An automated signing job that re-signs the zone on a schedule stopped running, silently, with no alert to say it had failed.
- A zone was moved, paused, or put behind a maintenance flag, which also paused the signing pipeline behind the scenes without anyone flagging DNSSEC specifically.
- A self-hosted or on-premise signer ran out of disk space, hit a permissions error, or crashed, and nobody was watching that particular log.
- A manual key ceremony process was used instead of an automated one, and the person responsible for periodically re-signing simply missed the window.
Whatever the cause, the underlying record data is rarely the actual problem. The signature's expiration timestamp is a countdown that keeps running whether or not anyone is paying attention, and once it hits zero, every validating resolver on the internet starts rejecting that record at the same time.
An RRSIG is not a permanent stamp of approval, it is a lease with an expiration date. Re-signing is not a one-time setup step, it is an ongoing job that has to keep running for as long as DNSSEC stays on. A signed zone that nobody is actively re-signing is a zone on a timer, counting down to an outage that looks like nothing changed, because nothing did, except the clock.
The fix, as a flow
Confirm the RRSIG really has expired, confirm the record it protects is still correct, then trigger a fresh signing pass at the DNS host so a new RRSIG with a future expiration date replaces the stale one.
How to fix it
Read the RRSIG expiration field for the failing name
Query the record with DNSSEC data attached and look at the RRSIG line. It carries two timestamps, an inception date and an expiration date, both in YYYYMMDDHHMMSS format. Compare the expiration date against the current UTC time.
dig +dnssec A example.com +noall +answer
# example.com. 3600 IN A 203.0.113.10
# example.com. 3600 IN RRSIG A 13 2 3600 20260625000000 20260611000000 2371 example.com. AbC123...
# ^^^^^^^^^^^^^^ expiration ^^^^^^^^^^^^^^ inception
date -u +%Y%m%d%H%M%S
# compare this against the expiration field above
Confirm the underlying record is still correct
Query the same record with checking disabled to see the plain answer with no DNSSEC validation applied. If the A, MX, or other record still looks right, the record is not the problem, only its signature has gone stale.
# Validating query, expect SERVFAIL once the RRSIG has expired
dig +dnssec example.com @1.1.1.1
# Same query, checking disabled, expect a normal answer
dig +cd +dnssec example.com @1.1.1.1
Check the DNS host's signing status
Most managed DNS hosts expose a DNSSEC status for the zone, showing whether automatic signing is active and healthy. Confirm it has not been disabled, paused, or left in an error state.
curl -s -H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" \
"https://api.cloudflare.com/client/v4/zones/$CLOUDFLARE_ZONE_ID/dnssec" | python3 -m json.tool
# look at result.status: expect "active", not "disabled" or "error"
Trigger a re-sign at the DNS host
On a managed host, re-asserting the DNSSEC status forces the zone to re-sign, which issues fresh RRSIG records with a new expiration window. On Cloudflare this is a PATCH call against the zone's DNSSEC endpoint.
PATCH /client/v4/zones/{zone_id}/dnssec
Authorization: Bearer {CLOUDFLARE_API_TOKEN}
Content-Type: application/json
{"status": "active"}
# Expect result.status: "active" in the response, and a new RRSIG
# expiration date on the next dig query, roughly one signing cycle later.
If the signer is self-hosted, run the re-sign manually
On a self-managed BIND or Knot DNS setup, re-signing means running the zone signer again (for example dnssec-signzone) and reloading the zone. Fix whatever stopped it first, disk space, permissions, a stuck cron job, then confirm the new signatures carry a future expiration date.
How to check it worked
Re-run the RRSIG query and confirm the expiration date is now well in the future. Then re-run the validating query and confirm it returns a normal answer with the authenticated data flag set.
# 1. Confirm the RRSIG expiration is now in the future
dig +dnssec A example.com +noall +answer
# 2. Confirm validation now passes, look for the "ad" flag in the header
dig +dnssec example.com @1.1.1.1
# 3. Confirm no other record type in the zone is still on a stale signature
dig +dnssec MX example.com @1.1.1.1
dig +dnssec TXT example.com @1.1.1.1
# 4. Cross-check with an online DNSSEC analyzer
# https://dnsviz.net/d/example.com/dnssec/
A good result looks like this: the RRSIG expiration field on every record type in the zone shows a date well in the future, not a date that has already passed. The validating query against 1.1.1.1 or 8.8.8.8 returns a normal answer with the ad (Authenticated Data) flag set, and no SERVFAIL anywhere in the zone. On dnsviz.net, the signature validity window should show as green and current, with no red "expired" markers on any RRSIG.
The full code
Here is a small script in each language that queries the RRSIG expiration for a name, flags any signature that has expired or is about to, and, when told to, triggers a re-sign through the Cloudflare DNS API. Zones signed with an offline or self-hosted signer still need a person to run that signer directly.
View this code on GitHub Full runnable folder with tests in the dns-fixes repo.
"""Detect RRSIG signatures that have expired or are close to expiring, and
optionally trigger a re-sign through the Cloudflare DNS API. Safe by
default. Set DRY_RUN=false to let it write.
"""
import os
import logging
from datetime import datetime, timezone
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("expired_rrsig_signatures")
DNS_DOMAIN = os.environ.get("DNS_DOMAIN", "example.com")
RECORD_TYPE = os.environ.get("RECORD_TYPE", "A")
WARN_HOURS = int(os.environ.get("WARN_HOURS", "48"))
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"
def check_rrsig_expiration(expiration, now, warn_hours):
"""Pure decision function. No I/O.
expiration: aware datetime, the RRSIG record's expiration timestamp
now: aware datetime, the current time to compare against
warn_hours: int, how many hours before expiration counts as "soon"
Returns one of "expired", "expiring_soon", or "ok".
"""
remaining = (expiration - now).total_seconds() / 3600
if remaining <= 0:
return "expired"
if remaining <= warn_hours:
return "expiring_soon"
return "ok"
def query_rrsig_expiration(domain, record_type):
"""Query the RRSIG record for a name and return its expiration as an
aware UTC datetime. Requires network.
"""
import dns.resolver
import dns.rdatatype
answer = dns.resolver.resolve(domain, "RRSIG")
for rdata in answer:
if dns.rdatatype.to_text(rdata.type_covered) == record_type:
return datetime.fromtimestamp(rdata.expiration, tz=timezone.utc)
raise LookupError(f"No RRSIG covering {record_type} found for {domain}")
def get_cloudflare_dnssec_status(zone_id, token):
"""Read the current DNSSEC status for a Cloudflare zone."""
import requests
headers = {"Authorization": f"Bearer {token}"}
url = f"{CF_API}/zones/{zone_id}/dnssec"
r = requests.get(url, headers=headers, timeout=30)
r.raise_for_status()
return r.json().get("result", {})
def trigger_resign(zone_id, token):
"""Force Cloudflare to re-assert DNSSEC as active, which triggers a
fresh signing pass and issues new RRSIG records with a new expiration.
"""
import requests
if DRY_RUN:
log.info("[dry run] would PATCH DNSSEC status to active for zone %s", zone_id)
return
headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
url = f"{CF_API}/zones/{zone_id}/dnssec"
requests.patch(url, headers=headers, json={"status": "active"}, timeout=30).raise_for_status()
log.info("Triggered a re-sign for zone %s", zone_id)
def run():
expiration = query_rrsig_expiration(DNS_DOMAIN, RECORD_TYPE)
now = datetime.now(timezone.utc)
state = check_rrsig_expiration(expiration, now, WARN_HOURS)
if state == "ok":
log.info("RRSIG for %s %s is valid until %s.", DNS_DOMAIN, RECORD_TYPE, expiration.isoformat())
return
log.warning(
"RRSIG for %s %s is %s (expiration %s, now %s).",
DNS_DOMAIN, RECORD_TYPE, state, expiration.isoformat(), now.isoformat(),
)
if not (CLOUDFLARE_API_TOKEN and CLOUDFLARE_ZONE_ID):
log.warning(
"No Cloudflare credentials set. If this zone uses a self-hosted "
"or offline signer, re-sign it manually and reload the zone."
)
return
status = get_cloudflare_dnssec_status(CLOUDFLARE_ZONE_ID, CLOUDFLARE_API_TOKEN)
log.info("Current Cloudflare DNSSEC status: %s", status.get("status"))
trigger_resign(CLOUDFLARE_ZONE_ID, CLOUDFLARE_API_TOKEN)
if __name__ == "__main__":
run()
/**
* Detect RRSIG signatures that have expired or are close to expiring, and
* optionally trigger a re-sign through the Cloudflare DNS API. Safe by
* default. Set DRY_RUN=false to let it write.
*/
import { pathToFileURL } from "node:url";
const DNS_DOMAIN = process.env.DNS_DOMAIN || "example.com";
const RECORD_TYPE = process.env.RECORD_TYPE || "A";
const WARN_HOURS = Number(process.env.WARN_HOURS || 48);
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";
/**
* Pure decision function. No I/O.
*
* expiration: Date, the RRSIG record's expiration timestamp
* now: Date, the current time to compare against
* warnHours: number, how many hours before expiration counts as "soon"
*
* Returns one of "expired", "expiring_soon", or "ok".
*/
export function checkRrsigExpiration(expiration, now, warnHours) {
const remainingHours = (expiration.getTime() - now.getTime()) / 3600000;
if (remainingHours <= 0) return "expired";
if (remainingHours <= warnHours) return "expiring_soon";
return "ok";
}
/**
* Query the RRSIG record for a name and return its expiration as a Date.
* Requires network. Node's built-in dns module does not expose RRSIG
* directly, so this uses resolveAny and filters for the RRSIG type.
*/
async function queryRrsigExpiration(domain, recordType) {
const dns = await import("node:dns/promises");
const records = await dns.resolveAny(domain);
const rrsig = records.find(
(r) => r.type === "RRSIG" && r.typeCovered === recordType
);
if (!rrsig) throw new Error(`No RRSIG covering ${recordType} found for ${domain}`);
return new Date(rrsig.expiration * 1000);
}
/** Read the current DNSSEC status for a Cloudflare zone. */
async function getCloudflareDnssecStatus(zoneId, token) {
const url = `${CF_API}/zones/${zoneId}/dnssec`;
const res = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
if (!res.ok) throw new Error(`Cloudflare DNSSEC read failed: ${res.status}`);
const body = await res.json();
return body.result || {};
}
/**
* Force Cloudflare to re-assert DNSSEC as active, which triggers a fresh
* signing pass and issues new RRSIG records with a new expiration.
*/
async function triggerResign(zoneId, token) {
if (DRY_RUN) {
console.log(`[dry run] would PATCH DNSSEC status to active for zone ${zoneId}`);
return;
}
const headers = { Authorization: `Bearer ${token}`, "Content-Type": "application/json" };
const url = `${CF_API}/zones/${zoneId}/dnssec`;
const res = await fetch(url, { method: "PATCH", headers, body: JSON.stringify({ status: "active" }) });
if (!res.ok) throw new Error(`Cloudflare DNSSEC patch failed: ${res.status}`);
console.log(`Triggered a re-sign for zone ${zoneId}`);
}
async function run() {
const expiration = await queryRrsigExpiration(DNS_DOMAIN, RECORD_TYPE);
const now = new Date();
const state = checkRrsigExpiration(expiration, now, WARN_HOURS);
if (state === "ok") {
console.log(`RRSIG for ${DNS_DOMAIN} ${RECORD_TYPE} is valid until ${expiration.toISOString()}.`);
return;
}
console.warn(
`RRSIG for ${DNS_DOMAIN} ${RECORD_TYPE} is ${state} (expiration ${expiration.toISOString()}, now ${now.toISOString()}).`
);
if (!(CLOUDFLARE_API_TOKEN && CLOUDFLARE_ZONE_ID)) {
console.warn(
"No Cloudflare credentials set. If this zone uses a self-hosted " +
"or offline signer, re-sign it manually and reload the zone."
);
return;
}
const status = await getCloudflareDnssecStatus(CLOUDFLARE_ZONE_ID, CLOUDFLARE_API_TOKEN);
console.log(`Current Cloudflare DNSSEC status: ${status.status}`);
await triggerResign(CLOUDFLARE_ZONE_ID, CLOUDFLARE_API_TOKEN);
}
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 expiration rule is the part worth testing, because it decides whether the script warns, tries a re-sign, or does nothing. It takes plain dates, no network, no DNS lookups, so the tests run in milliseconds.
from datetime import datetime, timedelta, timezone
from expired_rrsig_signatures import check_rrsig_expiration
NOW = datetime(2026, 7, 11, 12, 0, 0, tzinfo=timezone.utc)
def test_ok_when_expiration_far_in_future():
expiration = NOW + timedelta(days=10)
assert check_rrsig_expiration(expiration, NOW, 48) == "ok"
def test_expiring_soon_within_warn_window():
expiration = NOW + timedelta(hours=12)
assert check_rrsig_expiration(expiration, NOW, 48) == "expiring_soon"
def test_expired_when_expiration_already_passed():
expiration = NOW - timedelta(hours=1)
assert check_rrsig_expiration(expiration, NOW, 48) == "expired"
def test_expired_exactly_at_boundary():
assert check_rrsig_expiration(NOW, NOW, 48) == "expired"
import { test } from "node:test";
import assert from "node:assert/strict";
import { checkRrsigExpiration } from "./expired-rrsig-signatures.js";
const NOW = new Date("2026-07-11T12:00:00Z");
test("ok when expiration far in future", () => {
const expiration = new Date(NOW.getTime() + 10 * 24 * 3600000);
assert.equal(checkRrsigExpiration(expiration, NOW, 48), "ok");
});
test("expiring soon within warn window", () => {
const expiration = new Date(NOW.getTime() + 12 * 3600000);
assert.equal(checkRrsigExpiration(expiration, NOW, 48), "expiring_soon");
});
test("expired when expiration already passed", () => {
const expiration = new Date(NOW.getTime() - 3600000);
assert.equal(checkRrsigExpiration(expiration, NOW, 48), "expired");
});
test("expired exactly at boundary", () => {
assert.equal(checkRrsigExpiration(NOW, NOW, 48), "expired");
});
Case studies
The re-signing job that stopped without telling anyone
A team ran DNSSEC signing through a self-hosted signer on a nightly cron job. A permissions change during a server update quietly broke the job's ability to write the new zone file. No alert fired, because nothing was watching that specific cron job's exit code, only the general server logs.
Twelve days later, right on schedule with the signature's original expiration window, every validating resolver started returning SERVFAIL at once. Checking the RRSIG expiration field with dig showed the exact moment it had passed. Fixing the permissions and re-running the signer restored a clean chain within the hour.
The zone that moved but left DNSSEC behind
A store migrated its DNS hosting to a new provider and copied over every record by hand, including the DNSKEY, but the new provider's automatic re-signing was never turned on because the DNSSEC toggle defaulted to off after the import. The RRSIG records that came over in the copy kept their original expiration dates from the old provider.
Nobody noticed until those old signatures expired a week later. Turning on automatic signing at the new provider issued fresh RRSIG records immediately, and a follow-up dig query confirmed the new expiration date was safely in the future.
A healthy signed zone always has RRSIG records whose expiration date sits comfortably in the future, refreshed well before the deadline by an automated signing job that is actually being monitored. When that is in place, validating resolvers return the ad flag and normal answers every time, and the expiration date becomes something nobody has to think about, because the re-signing loop never stops running.
FAQ
Why did my signed domain suddenly start failing DNSSEC validation?
Every RRSIG record carries an expiration date. If the zone stops being re-signed, whether because signing was paused, a key went stale, or an automated signer failed silently, the signatures on your records run past that date. A validating resolver checks the expiration first and refuses to trust an expired signature no matter how correct the underlying data is, so it returns SERVFAIL.
How do I know if this is an expired RRSIG and not something else?
Run dig +dnssec on the name and look at the RRSIG record's expiration field, then compare it against the current date. If that date has already passed and a query with +cd (checking disabled) still returns a normal answer, the records are fine and only the signature has gone stale.
Can a script fix expired RRSIG signatures automatically?
Yes, when the zone is hosted at a provider with a re-sign API, such as Cloudflare. A script can detect signatures that are expired or close to expiring and call the provider's DNS API to force a re-sign, which issues fresh RRSIG records with a new expiration window. Zones signed manually with offline keys need a person to run the signer.
Related field notes
Citations
On the problem:
- RFC 4034: Resource Records for the DNS Security Extensions (RRSIG format and expiration field). datatracker.ietf.org/doc/html/rfc4034
- RFC 4035: Protocol Modifications for the DNS Security Extensions (validator behavior on expired signatures). datatracker.ietf.org/doc/html/rfc4035
- Cloudflare DNS docs: Troubleshooting DNSSEC. developers.cloudflare.com/dns/dnssec/troubleshooting
On the solution:
- Cloudflare DNS docs: DNSSEC. developers.cloudflare.com/dns/dnssec
- Cloudflare API docs: Edit DNSSEC status. developers.cloudflare.com/api/operations/dnssec-edit-dnssec-status
- AWS re:Post: Troubleshoot DNS SERVFAIL responses in Route 53 (expired RRSIG). repost.aws/knowledge-center/route53-dns-servfail-response
Stuck on a tricky one?
If you have a DNS, delegation, or domain security problem you would rather hand off, this is the kind of work I do. Message me and we can work through it together.
Did this fix your DNSSEC validation?
If this helped clear a SERVFAIL you were chasing, 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