tutorials
Generate and rotate DKIM keys for sending on Cloudflare
September 2, 2026

DKIM is the one piece of email auth where a single stray newline in a DNS record silently turns every outbound message into an unsigned one. Generating the key is the easy part. Publishing it correctly, signing the right headers, and rotating without a gap are where senders quietly break their own deliverability.
DKIM (DomainKeys Identified Mail) lets a receiver confirm that mail claiming to be from your domain was signed by someone holding your private key, and that the signed parts were not altered in transit. It is half of what DMARC needs to pass (SPF being the other half), and on a new sending domain it is non-negotiable: Gmail and Yahoo bulk sender rules treat missing DKIM as a reason to throttle or reject. It is one box on the Gmail and Yahoo bulk sender checklist you have to clear before a first campaign.
This is the from-scratch path: generate the keypair, format and publish the public key in Cloudflare DNS, sign a message, verify it passes, and rotate the key without dropping a single in-flight signature. It is genuinely worth doing once by hand so you understand what breaks. At the end, the shortcut if you would rather not own this glue.
How do I generate a DKIM key for sending email?
Create an RSA keypair with openssl, sign mail with the private half, and publish the public half in DNS. RSA 2048-bit is the right default in 2026: 1024-bit is widely treated as weak, and Ed25519 (DKIM k=ed25519) has better key sizes but inconsistent receiver support, so most senders still publish RSA, optionally alongside an Ed25519 key.
# 1. Generate a 2048-bit private key (this signs your mail)
openssl genrsa -out dkim-private.pem 2048
# 2. Extract the matching public key (this goes into DNS)
openssl rsa -in dkim-private.pem -pubout -out dkim-public.pem
The private key is a credential. Treat it like one: put it in wrangler secret, a KV/secret store, or your platform's secret manager, never in the repo and never in DNS. If it leaks, anyone can sign mail as your domain until you rotate it out.
Now format the public key for DNS. The PEM file has a header, footer, and line breaks that must all be removed; DNS wants one continuous base64 blob.
# Strip the PEM armor and newlines to get the bare base64 for the p= tag
grep -v 'PUBLIC KEY' dkim-public.pem | tr -d '\n'
That output is your p= value. The full TXT record value is:
v=DKIM1; k=rsa; p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA...rest-of-base64...
The single most common DKIM failure is a stray space or newline sneaking into that p= value. Copy it as one unbroken string.
Where does the DKIM record go in Cloudflare DNS?
The public key goes in a TXT record at <selector>._domainkey.<your-domain>. The selector is a label you choose; it namespaces the key so you can have more than one live at a time. Pick something dated and descriptive that you will never reuse, like k202608.
In the Cloudflare dashboard, add a DNS record:
- Type: TXT
- Name:
k202608._domainkey(Cloudflare appends your zone automatically, givingk202608._domainkey.example.com) - Content:
v=DKIM1; k=rsa; p=MIIBIjANBg... - TTL: Auto
Or via the API with a Worker / curl:
curl -X POST \
"https://api.cloudflare.com/client/v4/zones/${ZONE_ID}/dns_records" \
-H "Authorization: Bearer ${CF_API_TOKEN}" \
-H "Content-Type: application/json" \
--data '{
"type": "TXT",
"name": "k202608._domainkey",
"content": "v=DKIM1; k=rsa; p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A...",
"ttl": 1
}'
A 2048-bit RSA public key in base64 is longer than the 255-character limit of a single DNS TXT string, so the record is stored as multiple quoted strings that resolvers concatenate. Cloudflare handles the splitting for you; the gotcha appears at lookup time, covered below.
Confirm it published:
dig +short TXT k202608._domainkey.example.com
"v=DKIM1; k=rsa; p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA" "...rest...IDAQAB"
Note the two quoted strings. That is expected. Your DKIM verifier joins them with no separator. If you ever assemble the value yourself and join the chunks with a space, signing breaks, because the reconstructed key no longer matches.
Can a Cloudflare Worker sign DKIM itself?
It can compute the signature, because the Workers runtime exposes Web Crypto with RSASSA-PKCS1-v1_5 and SHA-256, which is exactly the rsa-sha256 algorithm DKIM uses. The hard part is not the crypto, it is the canonicalization: producing the exact byte sequence of headers and body that the receiver will reconstruct and hash. Get relaxed canonicalization, header folding, or trailing CRLF handling wrong and the signature is valid crypto over the wrong bytes, so it fails at the receiver.
Here is the signing core in a Worker, to show the shape of it. This signs the body hash and header hash; a production signer also has to canonicalize headers exactly per RFC 6376.
// Minimal DKIM rsa-sha256 signing primitive on Workers' Web Crypto.
// Canonicalization is simplified here; a real signer must follow RFC 6376.
async function importPkcs8(pem: string): Promise<CryptoKey> {
const b64 = pem
.replace(/-----BEGIN PRIVATE KEY-----/, "")
.replace(/-----END PRIVATE KEY-----/, "")
.replace(/\s+/g, "");
const der = Uint8Array.from(atob(b64), (c) => c.charCodeAt(0));
return crypto.subtle.importKey(
"pkcs8",
der,
{ name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" },
false,
["sign"],
);
}
async function sha256Base64(input: string): Promise<string> {
const hash = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(input));
return btoa(String.fromCharCode(...new Uint8Array(hash)));
}
async function signHeaders(canonicalizedHeaders: string, key: CryptoKey): Promise<string> {
const sig = await crypto.subtle.sign(
"RSASSA-PKCS1-v1_5",
key,
new TextEncoder().encode(canonicalizedHeaders),
);
return btoa(String.fromCharCode(...new Uint8Array(sig)));
}
The resulting DKIM-Signature header looks like this, with the selector (s=) matching the DNS record you published:
DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=example.com;
s=k202608; h=from:to:subject:date:message-id; bh=<body-hash>;
b=<signature>
Note openssl genrsa produces a PKCS1 (BEGIN RSA PRIVATE KEY) PEM, but Web Crypto's importKey wants PKCS8 (BEGIN PRIVATE KEY). Convert once:
openssl pkcs8 -topk8 -nocrypt -in dkim-private.pem -out dkim-pkcs8.pem
Forget that conversion and importKey throws DataError: Invalid keyData. That is the first failure mode most people hit signing DKIM in a Worker.
Because canonicalization is so fiddly, plenty of senders sign at the relay (the service that actually injects mail into the SMTP path) rather than hand-rolling it on the edge. Cloudflare Workers cannot open outbound port 25 for direct-to-MX SMTP anyway (no outbound port 25), which is why edge email goes out over an HTTPS sender, and that sender is usually where the signing lives.
How do I verify the DKIM signature actually passes?
Send a test message and read the Authentication-Results header for dkim=pass. The signature existing is not the same as it passing; a receiver only trusts it after fetching your public key and confirming both the header hash and the body hash.
The cheapest check is to mail yourself at a Gmail account, open the message, choose "Show original", and look for:
Authentication-Results: mx.google.com;
dkim=pass header.i=@example.com header.s=k202608;
spf=pass ...;
dmarc=pass ...
header.s=k202608 confirms the selector that verified. If you see dkim=fail or dkim=temperror, work through the failure table:
| Symptom | Likely cause | Fix |
|---|---|---|
dkim=permfail "no key for signature" | selector in s= does not match a published record | publish the selector or fix s= |
dkim=permfail "body hash did not verify" | body altered after signing, or wrong canonicalization | sign the final body; recheck relaxed vs simple |
dkim=permfail "signature did not verify" | p= has whitespace/newlines, or key mismatch | re-copy p= as one string; confirm the keypair |
dkim=temperror | DNS lookup failed or record split oddly | re-check dig TXT; confirm strings join with no space |
| no DKIM result at all | no DKIM-Signature header was added | confirm the signer ran and added the header |
For a non-interactive check, test SPF, DKIM, and DMARC together before you trust the setup, and verify the rest of your sending-domain DNS in the same pass so you are not debugging one record at a time. DKIM is one entry on a longer list: the seven DNS records every sending domain needs shows where it fits, and if the acronyms are blurring together, the email authentication acronyms explained one sentence each is the quick reference.
How do I rotate DKIM keys without breaking signing?
Rotate by overlapping two selectors: publish a fresh key under a new selector, switch your signer to it, wait out your message-retention window, then delete the old record. The mistake that causes an outage is deleting the old key the moment you switch. Mail you signed minutes ago may still be queued at a receiver; if its selector's record is already gone, verification fails and that message can land in spam.
The safe sequence:
- Generate a new keypair under a new dated selector, for example
k202702. Sameopensslsteps as above. - Publish the new TXT record at
k202702._domainkey.example.com. Leave the oldk202608record in place. - Verify the new record resolves with
dig +short TXT k202702._domainkey.example.combefore touching the signer. - Switch the signer to use selector
k202702(thes=tag inDKIM-Signature). Now new mail signs with the new key; both records are live. - Wait one full retention window. A conservative wait is a few days, long enough that anything signed with the old selector has been delivered and verified. If you have a Time-to-Live on the DNS records, account for cached lookups too.
- Delete the old
k202608record. Rotation is done. Keep the dated selector retired forever; never reuse a selector name with a different key.
# Step 1-2: new key + publish (old record stays untouched)
openssl genrsa -out dkim-k202702-private.pem 2048
openssl rsa -in dkim-k202702-private.pem -pubout | grep -v 'PUBLIC KEY' | tr -d '\n'
# ...publish as TXT at k202702._domainkey.example.com...
# Step 3: confirm the new key is visible before cutting over
dig +short TXT k202702._domainkey.example.com
# Step 6 (days later): remove the retired selector's record
# via Cloudflare dashboard or the DNS API DELETE on the old record id
Rotate on a schedule (every 6 to 12 months is common) and immediately on any suspected key exposure. Rotating on a calendar also means you have rehearsed the overlap-and-cutover dance before the day you actually need it under pressure.
Skip all of this: let Tinbox generate and rotate DKIM for you
Everything above is the manual path, and it is worth understanding. But the keygen, the PKCS8 conversion, the no-whitespace p= formatting, the selector discipline, and the overlap-then-retire rotation are exactly the kind of glue that is easy to get subtly wrong and tedious to maintain.
Tinbox generates a DKIM keypair on init, stores the private key as a Worker secret (never in DNS or your repo), and publishes the correctly formatted TXT record at a dated selector in your Cloudflare DNS for you. Signing happens in the sender path, so your outbound mail is signed without you hand-canonicalizing headers. When it is time to rotate, Tinbox publishes a new selector, cuts the signer over, and retires the old record on a schedule, keeping both keys valid through the overlap window so nothing in flight fails.
It runs entirely on your own Cloudflare account: your D1, your R2, your DNS zone, your keys. There is no per-message fee and no third party holding the private key that signs as your domain. For the rest of the auth picture, see the full SPF, DKIM, and DMARC setup guide and how Tinbox sends signed mail from a Worker over HTTPS. With DKIM passing, the next job on a fresh domain is reputation: the domain warmup tips for a brand-new sending domain cover the ramp.
Deploy this yourself: npx tinbox init <domain>. The init flow handles DKIM generation, DNS publishing, and rotation; the docs cover the selector naming and rotation cadence if you want to drive it manually.
Frequently asked questions
How do I generate a DKIM key for sending email?
Create an RSA keypair (2048-bit is the safe default) with openssl: 'openssl genrsa -out private.pem 2048' then 'openssl rsa -in private.pem -pubout -out public.pem'. The private key signs outbound mail; the public key, stripped of its PEM header and newlines, goes into a DNS TXT record at <selector>._domainkey.<your-domain> as 'v=DKIM1; k=rsa; p=<base64>'. Keep the private key out of DNS and out of your repo.
What is a DKIM selector and why does it matter for rotation?
A selector is a label (like s1, mail, or k202608) that namespaces a key under your domain, so the public key lives at <selector>._domainkey.<domain>. It matters for rotation because you rotate by publishing a new selector with a fresh key alongside the old one, switching your signer to the new selector, and only then retiring the old record. Selectors are how you have two valid keys live at once during a cutover.
Can a Cloudflare Worker sign DKIM itself?
It can compute the signature, but pure RSA signing in a Worker is awkward: the runtime's Web Crypto supports RSASSA-PKCS1-v1_5 with SHA-256, which is exactly what DKIM rsa-sha256 needs, so you can import the PKCS8 private key and sign the canonicalized header hash. In practice most senders sign at the relay or sender service rather than hand-rolling canonicalization in the Worker, because relaxed/simple canonicalization and header folding are easy to get wrong.
How often should I rotate DKIM keys?
Common guidance is every 6 to 12 months, plus immediately if a private key is ever exposed. Rotation also forces you to prove your publish-and-cutover process works before you need it in an emergency. The mechanism is the same either way: overlap two selectors, switch, wait out your retention window, then remove the old one.
Why is my DKIM signature failing even though the DNS record exists?
The usual causes: the published p= value has stray newlines or whitespace, the selector in your DKIM-Signature header (s=) does not match the published record, the key length in DNS does not match the key you sign with, a body or header was modified in transit so the hash no longer matches, or the record is split across TXT strings and your DNS provider joined them with a space. Check with 'dig TXT <selector>._domainkey.<domain>' and compare the p= value byte for byte.


