Rotating DKIM keys from Node 22: one API call, one DNS cutover
A practical DKIM rotation runbook: mint the new key with one free call, publish the TXT record, poll until the check flips, and know where the overlap window isn't.
Rotating a DKIM key is two jobs wearing one name. Minting a fresh key pair is an API call and takes milliseconds; publishing the public half at your DNS host and waiting for resolvers to agree is the part that decides whether your mail keeps authenticating. Infrai exposes the first as POST /v1/email/domain/rotate_dkim/{domain}, it’s free, and it hands back the exact TXT record you need to publish.
The second job is still yours, and getting the sequencing wrong is how teams turn a maintenance task into an hour of unsigned mail.
How often, and why
Six to twelve months is the cadence most operators land on for a 2048-bit key, plus an immediate rotation any time a key might have been exposed — a leaked backup, a departing contractor with DNS access, a provider migration. Rotating more often than quarterly buys very little; the private key never leaves the signing infrastructure, so the realistic threat is exposure, not brute force.
Shorter keys are a different story. If your zone still carries a 1024-bit selector because a registrar refused a long TXT value years ago, rotate now and split the record properly instead.
| Approach | Who holds the private key | Rotation effort | Overlap window |
|---|---|---|---|
| Self-signing with Nodemailer | You, in your app config | Generate, deploy, publish, retire | You control both selectors |
| Amazon SES Easy DKIM | AWS | Managed, CNAME-based | Provider handles the roll |
| Postmark / Mailgun managed keys | Provider | Dashboard action + DNS edit | Provider-defined |
Infrai rotate_dkim | Infrai | One call + DNS edit | Single selector, see below |
Self-signing is the honest choice if you need two live selectors at once and full control of the retirement schedule — Nodemailer will sign with a key you generate, and nobody can take that away from you. The cost is that you now operate key material.
The rotation call
No request body is required. The path parameter is the domain itself, so the call is concrete and easy to script.
curl -s -X POST https://api.infrai.cc/v1/email/domain/rotate_dkim/example.com \
-H "Authorization: Bearer ${INFRAI_API_KEY:-your_infrai_api_key}" \
-H "Content-Type: application/json" \
-d '{}'
{
"ok": true,
"data": {
"domain": "example.com",
"domain_id": "dom_Tt26LnNxF0eqNNdmHxGN2Ryk",
"status": "pending_dns",
"dns_records": [
{ "type": "TXT", "name": "example.com", "value": "v=spf1 include:_spf.infrai.cc ~all", "purpose": "spf", "ttl_recommended": 3600 },
{ "type": "TXT", "name": "cf._domainkey.example.com", "value": "v=DKIM1;k=rsa;p=MIIBIj...AB", "purpose": "dkim", "ttl_recommended": 3600 },
{ "type": "CNAME", "name": "track.example.com", "value": "tracking.infrai.cc", "purpose": "tracking", "ttl_recommended": 3600 },
{ "type": "TXT", "name": "_dmarc.example.com", "value": "v=DMARC1;p=none;rua=mailto:dmarc@infrai.cc", "purpose": "dmarc", "ttl_recommended": 3600 }
],
"warm_up_state": "not_started",
"rotated": true
}
}
Two fields carry the meaning. rotated: true confirms new key material exists on the provider side, and status tells you the domain is waiting on DNS — it is not authenticated again until the record resolves. Everything in dns_records comes back, not just the changed one, which is convenient for a config-as-code workflow where you write the whole set to your zone file each time.
Watch exactly one field
GET /v1/email/domain/get/{domain} reports each authentication record separately, and checks.dkim_dns is the one that moves during a rotation.
curl -s https://api.infrai.cc/v1/email/domain/get/example.com \
-H "Authorization: Bearer ${INFRAI_API_KEY:-your_infrai_api_key}"
{
"ok": true,
"data": {
"verification": {
"domain": "example.com",
"status": "verified",
"checks": {
"spf_dns": "verified",
"dkim_dns": "verified",
"tracking_cname": "verified",
"dmarc_dns": "verified",
"mail_loopback": "verified"
}
},
"reputation": {
"tier": "warming_up",
"current_daily_cap": 50000,
"used_today": 0,
"throttle_risk": "low"
}
}
}
Confirm the same thing from outside the platform before you trust it. Resolvers disagree, and a record that satisfies the provider’s checker can still be missing from a public resolver’s cache:
dig +short TXT cf._domainkey.example.com @1.1.1.1
The runbook, in Node 22
This is the script to keep in your ops repo. It rotates, prints the records a human has to publish, then polls until the DKIM check goes green or a deadline passes — and it exits non-zero on failure so a scheduler notices.
// rotate-dkim.mjs — Node 22, no dependencies
const API = "https://api.infrai.cc";
const KEY = process.env.INFRAI_API_KEY;
if (!KEY) throw new Error("INFRAI_API_KEY is not set");
const domain = process.argv[2];
if (!domain) throw new Error("usage: node rotate-dkim.mjs <domain>");
const headers = { authorization: `Bearer ${KEY}`, "content-type": "application/json" };
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
async function call(path, init = {}) {
const res = await fetch(`${API}${path}`, { headers, ...init });
const payload = await res.json().catch(() => ({}));
if (!res.ok || payload.ok === false) {
const err = payload.error ?? { code: `HTTP_${res.status}`, message: res.statusText };
throw new Error(`${err.code}: ${err.message}`);
}
return payload.data;
}
async function rotate(name) {
const out = await call(`/v1/email/domain/rotate_dkim/${encodeURIComponent(name)}`, {
method: "POST",
body: JSON.stringify({}),
});
const dkim = out.dns_records.find((r) => r.purpose === "dkim");
console.log("publish this TXT record now:");
console.log(` ${dkim.name} -> ${dkim.value} (ttl ${dkim.ttl_recommended})`);
return out;
}
async function waitForDkim(name, { timeoutMs = 45 * 60_000, everyMs = 60_000 } = {}) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const data = await call(`/v1/email/domain/get/${encodeURIComponent(name)}`);
const state = data.verification?.checks?.dkim_dns ?? "unknown";
console.log(`${new Date().toISOString()} dkim_dns=${state}`);
if (state === "verified") return true;
await wait(everyMs);
}
return false;
}
await rotate(domain);
const ok = await waitForDkim(domain);
if (!ok) {
console.error(`dkim_dns never reached verified for ${domain}`);
process.exit(1);
}
console.log(`${domain} is signing with the new key`);
Run it as INFRAI_API_KEY=your_infrai_api_key node rotate-dkim.mjs example.com. A 60-second poll against a free route is cheap; polling every second is rude and buys nothing, because DNS propagation is measured in minutes.
The overlap window you don’t get
Here’s the caveat that decides your maintenance window. The API exposes a single DKIM selector per domain, so a rotation is a cutover rather than a graceful roll — there’s no second selector signing in parallel while the first drains. Between the moment the new key becomes active and the moment your zone serves the matching public key, signatures won’t validate. In practice that means three things: rotate during your lowest-volume hour, keep the TTL at the recommended 3600 seconds so a mistake ages out within an hour rather than a day, and publish the new record before you consider the rotation done rather than after.
If you need true dual-selector overlap — a regulated environment, or a domain where an hour of unsigned mail is unacceptable — this doesn’t support it today, and you’d be better off self-signing with Nodemailer or using a provider whose managed rotation does the roll for you.
Production checklist
- Rotate on a schedule, not on a reminder — put
rotate-dkim.mjsbehind a job and alert on its exit code. - Keep DMARC at
p=nonewith aruaaddress while you rotate, then tighten once a full reporting cycle looks clean. - Verify from a public resolver as well as from the API; the two can disagree for the length of the old TTL.
- Never delete the old TXT record until the new check reports
verified, and never publish both values at the same record name. - Send one real message afterwards and confirm the receiving MTA reports
dkim=passin the Authentication-Results header.POST /v1/email/sendis the only billable call in this whole runbook; the domain routes cost nothing.