DKIM Key Rotation with Dual Selectors: Retire Old Keys With Zero Verification Downtime
Overwriting your DKIM TXT record in place strands every message already signed with the old key but not yet verified. The fix is a staged cutover between two live selectors with a deliberate overlap window. Here's the exact process, the rspamd and OpenDKIM config, and how to size the overlap so retirement is genuinely zero-downtime.
EvilMail TeamJuly 16, 202610 min read
Someone on your team decides it's time to rotate DKIM. They run opendkim-genkey, paste the fresh p= value over the existing default._domainkey TXT record, reload the signer, and close the ticket. It looks clean. dig shows the new key, test mail passes.
It isn't clean. Watch what actually happened at the wire level:
bash
# before the swap — old public key, matches the private key currently signing
$ dig +short TXT default._domainkey.example.com
"v=DKIM1; k=rsa; p=MIGfMA0...OLD..."
# 40 minutes later, after they overwrote it
$ dig +short TXT default._domainkey.example.com
"v=DKIM1; k=rsa; p=MIGfMA0...NEW..."
Every message signed with the OLD private key that gets verified AFTER that swap now pulls the NEW public key from DNS and fails. The body hash can't reconcile against a key that never signed it. That's not a hypothetical — it's your queue tail, your greylisted retries, forwarded copies, and mailing-list traffic that re-verifies downstream hours or days later. They all fail DKIM silently, and the only place it surfaces is your DMARC aggregate reports quietly filling with
dkim=fail
.
The mental model that causes this is treating a selector as a name you reuse. It isn't. A selector is an immutable, versioned key slot. You don't rotate a key by mutating a selector — you stand up a second selector, cut over to it, and retire the first only after nothing anywhere can still try to verify against it. This is the dual-selector cutover, and done right it has exactly zero verification downtime.
How DKIM selectors actually resolve
When your MTA signs a message, it stamps a DKIM-Signature header carrying (among other tags) d= for the domain and s= for the selector:
A receiving MTA does exactly one thing to find your public key: a single DNS TXT lookup at <selector>._domainkey.<domain> — here 202601._domainkey.example.com. It fetches the p= public key from that record, recomputes the body hash and the signed-header hash, and checks them against bh= and b=.
The detail everything here hinges on: verification happens at receive or re-verify time, never at send time. The receiver has no idea which private key you used. Its only pointer is the s= value baked into the message. So the public key referenced by an old selector must stay resolvable for as long as *any* system might verify a message carrying that selector — and that can be long after you've stopped signing with it.
Two more facts you'll lean on: DKIM supports both k=rsa and k=ed25519 (RFC 8463), and a single message can legitimately carry two DKIM-Signature headers. Both matter during a transition.
Why in-place rotation silently breaks auth
Reusing one selector gives you no safe instant to overwrite it. Here is the concrete failure taxonomy, each mapped to what shows up in your DMARC aggregate (RUA) reports:
Queue and retry tail. A message signed seconds before the swap sits in your outbound queue, or gets greylisted and retried by the receiver — and RFC 5321 recommends senders keep retrying for 4–5 days before giving up. When it finally lands and verifies, DNS already serves the new key. The report shows dkim=fail for your own domain.
Forwarding and mailing lists. A subscriber's .forward, a Mailman or Google Groups relay, or a corporate forwarder re-verifies your DKIM signature hours or days downstream. If the selector was overwritten, the archived message can never re-verify.
ARC chains and downstream re-checks. Intermediaries seal what they saw at receipt. If your key changed under them, later validators along the chain disagree with the sealed result.
TTL and negative-cache lag. During propagation, different resolvers hand out the old record, the new record, or momentarily nothing. Verification becomes a coin flip weighted by which resolver the receiver happened to query.
None of these produce a bounce. Mail is delivered; it just fails authentication, erodes your domain reputation, and — if your DMARC policy is p=reject or p=quarantine and SPF doesn't independently align — starts landing in spam. The fix is never "swap faster." It's "never swap in place."
The dual-selector cutover, stage by stage
Rotation is a staged cutover between two live selectors. The private key and the public key have different retirement moments, and conflating them is the whole mistake. You stop *using* the old private key at cutover; you stop *publishing* the old public key only after the overlap window.
Stage 0 — Prep (T-1 day). Lower the selector TXT TTL to 3600 (or 300) a day ahead so the eventual retirement propagates fast. Pick a dated selector name — 202601, never default.
Stage 1 — Publish. Generate the new keypair. Publish the NEW selector's public key in DNS. Keep signing with the OLD selector. Verify with dig and opendkim-testkey.
Stage 2 — Settle. Wait longer than the OLD record's TTL and confirm the new record resolves consistently from multiple public resolvers. Nothing is signing with it yet, so there's no risk here — take your time.
Stage 3 — Cutover. Flip the signer to the NEW selector's private key. Both public keys stay in DNS. From this instant, new mail carries s=202601; old in-flight mail still carries the old selector and still verifies against its still-published key.
Stage 4 — Drain / overlap. Keep the old selector published through the overlap window while old-signed mail drains, gets retried, and re-verifies everywhere.
Stage 5 — Retire. Remove the old selector's TXT record, shred the old private key, and restore the TTL to 86400.
Config: rspamd and OpenDKIM
Generate the new keypair with a dated selector. OpenDKIM's helper writes both halves:
openssl genrsa -out 202601.private 2048
openssl rsa -in 202601.private -pubout -outform der 2>/dev/null | openssl base64 -A
The published record for a 2048-bit RSA key looks like this — and the base64 p= exceeds a single 255-character DNS string, so it MUST be split into multiple quoted strings inside the same TXT record. A mangled split is the number-one cause of "key not found / verify fail":
202601._domainkey.example.com. 3600 IN TXT (
"v=DKIM1; k=rsa; t=s; "
"p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA..."
"...remainder of the base64 continues here..." )
Verify the published record actually matches your private key before you touch the signer, then confirm propagation from two independent resolvers:
During the transition you can go one better and double-sign — emit a signature from both selectors so the message verifies if *either* key resolves. This is bulletproof, and optional:
Then systemctl reload opendkim. If you're publishing an Ed25519 key alongside RSA (RFC 8463), its record is tiny — k=ed25519; p=<44-char base64>, no chunking — but keep the RSA selector live for receivers that still don't support ed25519.
Sizing the overlap window
The theoretical minimum overlap is max message lifetime + DNS TTL. With RFC 5321 retry windows running 4–5 days and a 3600s TTL, that's already north of five days. But real-world re-verification is what actually sets the number: forwarders, Google Groups and Mailman relays, archived-then-later-scanned mail, and ARC validators can re-check a signature well beyond the send queue's life.
Practical guidance:
Pure transactional, tight control over recipients: 1–2 weeks.
Mixed transactional + bulk, or any list/forwarding exposure: 30 days.
You can shorten this safely instead of guessing — watch your DMARC aggregate reports. They break down authentication results by s=. Retire the old selector only once its pass count reaches zero across a full 24h+ reporting window. If 202510 still shows up in yesterday's RUA, something is still emitting or re-verifying it; don't retire yet.
Two flag warnings that bite people. Never leave t=y (testing mode, receivers ignore failures) in a production record — it's a silent auth bypass. And when you finally revoke, you can tombstone a key by publishing an empty p= rather than deleting the record outright, which some operators prefer for auditability.
Automating quarterly rotation
Once the staged process is muscle memory, automate it. The naming convention does most of the work: date-based selectors (202601, 202604, 202607, 202610), never default or selector1. A quarterly systemd timer or cron job should:
1.Generate the next dated keypair and back up the private key.
2.Publish the new selector via your DNS provider's API.
3.Poll dig @1.1.1.1 and @8.8.8.8 until the record resolves consistently.
4.Flip the rspamd selector and reload.
5.Schedule the retirement job for now + overlap_window.
Wire monitoring to two signals: alert if the ACTIVE selector's DMARC pass rate drops after a cutover, and alert if a RETIRED selector reappears in aggregate reports. The second is your safety interlock — if a supposedly-dead selector shows up, retirement was premature and you keep the record live.
Rotation checklist
Pre-flight:
TTL on the selector TXT lowered to 3600/300, at least a day ahead.
New keypair generated (2048-bit RSA floor; add ed25519 if supported), private key backed up, chmod 600, chown to the signer.
New selector TXT published and confirmed byte-correct (watch the 255-char split).
opendkim-testkey ... -vvv returns key OK.
Record resolves identically from at least 3 independent resolvers.
Cutover:
Waited longer than the old record's TTL after publishing.
Signer flipped to the new selector (or double-signing enabled).
Old public key left in place — not touched.
Post / retirement:
DMARC aggregate reports watched; old selector's s= pass count observed dropping to zero across a full reporting window.
Overlap window fully elapsed (1–2 weeks minimum, 30 days for mixed senders).
Old TXT record removed (or tombstoned with empty p=), old private key shredded.
Selector TTL restored to 86400.
Rotate on a calendar, not on a breach. And whatever you do, stop overwriting default._domainkey in place — a selector is a version number, not a nameplate.