Short answer: run an unattended DKIM rotation job that publishes a new TXT selector, verifies resolver-visible data before switching the signer, and alerts on drift while keeping rollback possible.
A DKIM rotation should be treated as a reversible hostname cutover, with the published DNS state compared to an explicit intent ledger before mail traffic moves. For a property-management portal, that means keeping the old selector valid during propagation, publishing the new TXT value, verifying the exact record seen by resolvers, and alerting on drift instead of guessing from a successful API response.
How should an unattended DKIM rotation job publish and verify a sending domain?
The useful question is not whether a DNS write returned 200. It is whether the selector named by the signer, the TXT value visible from multiple resolvers, and the intended sending domain still agree. DKIM signatures identify a domain and selector in the d= and s= tags; receivers retrieve the public key at <selector>._domainkey.<domain> (RFC 6376). DMARC then evaluates alignment between that authenticated domain and the visible From domain (RFC 7489).
In this scenario, each building's resident-notice service sends through mail.example-property.com. A rotation job creates s2026q3, leaves s2026q2 published, and records a deadline for removing the old selector. The ledger is the control plane; DNS is a replicated data plane that can lag or be edited elsewhere.
The first implementation I reach for is deliberately boring: a typed record, an idempotent publisher interface, and a verifier that compares normalized TXT chunks.
type DkimIntent = {
domain: string;
selector: string;
publicKey: string;
expiresAt: string;
};
type DnsProvider = {
upsertTxt(name: string, value: string, ttlSeconds: number): Promise<void>;
deleteTxt(name: string, value: string): Promise<void>;
};
function dkimName(intent: DkimIntent): string {
return `${intent.selector}._domainkey.${intent.domain}`;
}
function canonicalTxt(chunks: string[]): string {
return chunks.join("").replace(/\s+/g, "");
}
export async function publishAndVerify(
intent: DkimIntent,
dns: DnsProvider,
resolveTxt: (name: string) => Promise<string[][]>
): Promise<void> {
const value = `v=DKIM1; k=rsa; p=${intent.publicKey}`;
const name = dkimName(intent);
await dns.upsertTxt(name, value, 300);
const answers = await resolveTxt(name);
const flattened = answers.map(canonicalTxt);
const expected = canonicalTxt([value]);
if (!flattened.includes(expected)) {
throw new Error(`DKIM intent drift at ${name}`);
}
}
The resolver adapter should query more than one recursive resolver and retain the observed answers. A provider acknowledgement is evidence that a request was accepted, not evidence that every receiver can read the same value. The code also keeps the old selector untouched; deletion belongs to a later, separately authorized step.
That distinction is easy to miss.
How do you keep intent from drifting?
Store one ledger row per selector: domain, selector, public-key fingerprint, intended TTL, publication timestamp, and retirement deadline. Hash the complete TXT value before writing it. On every run, compare the ledger hash with authoritative answers and with a small sample of public recursive resolvers. Treat extra TXT values as a review signal rather than silently replacing them; DKIM lookups can contain multiple strings, and an unrelated key at the same name should not be destroyed by an automated retry.
A rotation state machine makes rollback explicit: planned -> published -> observed -> active -> retired. The mail signer switches to active only after the new selector is observed from the resolver set. If verification fails, keep signing with the old selector and mark the attempt blocked. Rollback is then a pointer change in the signer plus preservation of the old DNS record, not an emergency reconstruction of a private key.
Property portfolios add a practical wrinkle: domains can be delegated to different DNS operators after an acquisition. Route 53, Cloudflare DNS, and PowerDNS Authoritative expose different APIs and propagation controls, but none can guarantee receiver cache state. Hide those differences behind the DnsProvider interface, and make the ledger the common audit format. This is an engineering boundary, not a reason to couple the rotation policy to one provider.
Where do unattended jobs fail?
The common failure is a partial success. The new TXT record exists, but the signing service still emits s2026q2; or the signer switches first and a recursive resolver still returns only the old key. Another trap is TXT quoting: DNS responses may split one logical value into several character-strings, so comparing raw arrays produces false drift. Normalize chunks, preserve the v=DKIM1 and p= fields, and reject malformed or empty keys.
It failed once in staging. The stale answer was the clue.
There is no universal winner among DNS operators. A provider-neutral interface adds code and limits provider-specific features, but that limitation buys a portable rollback path.
Retries must be idempotent and bounded. Use a job lease so two workers cannot retire selectors at once. Emit metrics for publish latency, verification age, resolver disagreement, and time remaining to expiresAt. Alert on intent drift, missing keys, and a signer selector that is absent from the ledger. Alert text should include domain, selector, last observed hash, and a rollback action; a generic “DNS failed” page is not actionable at 03:00.
I keep cost in the design review by measuring queries and retention, not by picking the cheapest DNS plan. Resolver checks every minute across dozens of buildings can become noisy and expensive; exponential backoff after the first successful observation usually gives better signal. Keep raw DNS observations for the incident window, then retain hashes and state transitions for audit.
An operational rule for the next rotation
Before deployment, test the state machine with a fake provider that returns stale, split, duplicated, and NXDOMAIN answers. In production, publish the new selector, wait for independent observations, then flip the signer. During the overlap, accept either selector at receivers and remove the old record only after its retirement deadline and a final drift check. If any check disagrees, stop progression, page the owner named in the ledger, and leave the last known-good signer in place.
That sequence keeps a hostname cutover boring: intent is reviewable, DNS changes are reversible, and an unattended process can prove what receivers are likely to see.
References
- https://datatracker.ietf.org/doc/html/rfc6376
- https://datatracker.ietf.org/doc/html/rfc7489
- https://www.rfc-editor.org/rfc/rfc1035
- https://docs.aws.amazon.com/Route53/latest/APIReference/Welcome.html
- https://developers.cloudflare.com/api/operations/dns-records-for-a-zone-dns-record-details
- https://doc.powerdns.com/authoritative/http-api/
Top comments (0)