Diagnostic Email Authentication
DKIM selector record missing or not propagated
Your mail platform says it is signing every message with DKIM, but verifiers keep reporting none or permfail. Nothing about the signing changed. What is missing is the one TXT record that lets the other side actually read your public key. Here is why that happens and how to publish the record correctly.
A DKIM signature carries a selector, like google or s1, that tells the receiver exactly which TXT record to fetch under selector._domainkey.yourdomain.com. If that record was never published, was published at the wrong host, or has not reached the resolver doing the check, the receiver gets NXDOMAIN or nothing back and cannot validate the signature, so DKIM reads none or permfail even though your platform is signing fine. The fix is to publish the exact TXT record your platform expects, at the exact selector name, in the zone that is actually authoritative for your domain. Full commands, records, and a checker script are below.
The problem in plain words
When your mail platform signs a message with DKIM, it adds a header with a signature and a selector name. The selector is not the key itself, it is a pointer. It tells whoever receives the mail "go look up this exact TXT record, under this exact name, and you will find the public key that matches this signature."
If nobody ever created that TXT record, or it was created under a slightly different name, or the DNS zone that actually serves your domain does not have it, the lookup comes back empty. The receiving mail server cannot get the public key, so it cannot check the signature at all. It does not matter how correctly your platform signed the message. Without the key, DKIM cannot say pass. It says none, because it found no usable signature to check, or permfail, because it found a signature but no key to validate it with.
Why it happens
- A mail provider was switched or added, and the new DKIM selector's TXT record was never created in DNS, only turned on inside the mail platform's settings.
- The record was created, but under the wrong host name, for example missing the
_domainkeypart, or built in the wrong DNS zone entirely. - Keys were rotated to a new selector, but only the old selector's record still exists, so the new signatures point at a name with nothing published.
- The domain's nameservers were recently changed, and the record was added to the old DNS provider, which is no longer the one actually answering for the domain.
- The record is genuinely correct but has not finished propagating, so some resolvers still return nothing while others already see it.
This shows up most often right after switching email providers, right after rotating DKIM keys, or in the first hour after adding the record, before propagation has caught up everywhere.
DKIM failing does not always mean signing is broken. The selector in the signature is a promise that a specific DNS record exists. If that promise is not kept, verification cannot even start. Always check the DNS record for the exact selector named in the signature before assuming anything is wrong with the signing key itself.
The fix, as a flow
Find the exact selector your platform is using, confirm which DNS zone is actually authoritative for the domain, publish the TXT record with that selector's public key at the correct host name, and then check the answer from more than one resolver before trusting it.
How to fix it
Confirm the record is really missing, not just slow
Query the exact selector your platform uses. A healthy record returns one or more quoted strings starting with v=DKIM1; k=rsa; p=.... An empty response, or a header showing status: NXDOMAIN, means the record does not exist at that name at all.
dig +short TXT google._domainkey.example.com
dig selector1._domainkey.example.com TXT +noall +comment
dig +trace TXT s1._domainkey.example.com
Check the record from more than one resolver
If one resolver returns the record and another returns nothing, the record exists but has not propagated everywhere yet, or an old negative answer is still cached. That is a different situation from a record that was never created anywhere.
dig @8.8.8.8 TXT google._domainkey.example.com
dig @1.1.1.1 TXT google._domainkey.example.com
Confirm which zone is actually authoritative
A common mistake is editing the wrong DNS provider after a nameserver migration. Check which nameservers the domain actually points to before adding anything, or you will publish the record in a zone nobody is querying.
dig NS example.com +short
Publish the exact TXT record, at the exact host, in the right zone
Copy the full public key string from your mail platform's DKIM setup page. Do not truncate it. For Google Workspace, go to Admin Console, Apps, Gmail, Authenticate email, and copy the value shown there exactly.
; Google Workspace example
google._domainkey.example.com 3600 IN TXT "v=DKIM1; k=rsa; p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA..."
; generic ESP example
s1._domainkey.example.com 3600 IN TXT "v=DKIM1; k=rsa; p=MIGfMA0GCSq..."
Create it in Cloudflare, by dashboard or by API
In the Cloudflare dashboard, go to DNS, Records, Add record, choose type TXT, enter the selector host and the full value, set a low TTL while rolling out, and save. To do the same thing through the API, send a create request against the zone's DNS records endpoint. Use a PUT or PATCH on the existing record ID instead if a stale record is already there.
curl -X POST "https://api.cloudflare.com/client/v4/zones/{zone_id}/dns_records" \
-H "Authorization: Bearer {CLOUDFLARE_API_TOKEN}" \
-H "Content-Type: application/json" \
--data '{"type":"TXT","name":"google._domainkey.example.com","content":"v=DKIM1; k=rsa; p=MIIBIjANBgkqhkiG...","ttl":3600}'
How to check it worked
Re-query the selector from at least two independent resolvers and confirm both return the identical v=DKIM1; k=rsa; p=... string. Then send a real test email to a mailbox at gmail.com or outlook.com and read the Authentication-Results header on the received copy.
dig @8.8.8.8 +short TXT google._domainkey.example.com
dig @1.1.1.1 +short TXT google._domainkey.example.com
# a good answer from both looks like:
# "v=DKIM1; k=rsa; p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA..."
# then check a delivered message's headers for:
# Authentication-Results: mx.google.com;
# dkim=pass header.i=@example.com
The full code
Here is a small script that checks a list of candidate selectors for a domain against multiple public resolvers, flags any that are missing or that do not start with the right DKIM tag, and, when told to repair, publishes or updates the TXT record through the Cloudflare API. It stays in dry run by default so it reports before it writes.
View this code on GitHub Full runnable folder with tests in the dns-fixes repo.
"""Detect a missing or stale DKIM selector record and repair it via Cloudflare.
Safe to run on a schedule. Stays in dry run until DRY_RUN=false.
"""
import os
import logging
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("dkim_selector_check")
DEFAULT_SELECTORS = ["google", "selector1", "selector2", "k1", "s1"]
def evaluate_dkim_selector(txt_answers: list, expected_selector: str, expected_pubkey_fragment: str = None) -> dict:
"""Pure decision function. No network, no I/O.
txt_answers is the list of TXT strings already resolved for
selector._domainkey.domain (empty list if NXDOMAIN or no answer).
"""
if not txt_answers:
return {"status": "missing", "reason": f"no TXT record found for selector '{expected_selector}'"}
value = txt_answers[0]
if not value.startswith("v=DKIM1"):
return {"status": "stale", "reason": "record exists but does not start with v=DKIM1"}
if expected_pubkey_fragment and expected_pubkey_fragment not in value:
return {"status": "stale", "reason": "record exists but public key does not match the expected key"}
return {"status": "ok", "reason": "record is present and matches expectations"}
def run():
# Imported lazily so the pure function above can be tested with no
# network libraries installed at all.
import dns.resolver
import requests
domain = os.environ["DNS_DOMAIN"]
selectors = [s.strip() for s in os.environ.get("DKIM_SELECTORS", ",".join(DEFAULT_SELECTORS)).split(",") if s.strip()]
expected_pubkey_fragment = os.environ.get("DKIM_PUBKEY_FRAGMENT") or None
new_record_value = os.environ.get("DKIM_RECORD_VALUE")
ttl = int(os.environ.get("RECORD_TTL", "3600"))
dry_run = os.environ.get("DRY_RUN", "true").lower() == "true"
zone_id = os.environ["CLOUDFLARE_ZONE_ID"]
api_token = os.environ["CLOUDFLARE_API_TOKEN"]
resolvers = {"8.8.8.8": dns.resolver.Resolver(), "1.1.1.1": dns.resolver.Resolver()}
for ip, resolver in resolvers.items():
resolver.nameservers = [ip]
for selector in selectors:
name = f"{selector}._domainkey.{domain}"
results = {}
for ip, resolver in resolvers.items():
try:
answer = resolver.resolve(name, "TXT")
results[ip] = ["".join(r.strings[0].decode() if isinstance(r.strings[0], bytes) else r.strings[0]
for r in [rdata]) for rdata in answer]
except (dns.resolver.NXDOMAIN, dns.resolver.NoAnswer):
results[ip] = []
txt_answers = results.get("8.8.8.8", [])
outcome = evaluate_dkim_selector(txt_answers, selector, expected_pubkey_fragment)
log.info("Selector %s (%s): %s -> %s", selector, name, outcome["status"], outcome["reason"])
if results.get("8.8.8.8") != results.get("1.1.1.1"):
log.warning("Selector %s disagrees between resolvers, likely propagation in progress", selector)
if outcome["status"] == "ok":
continue
if not new_record_value:
log.info("No DKIM_RECORD_VALUE set, skipping repair for %s.", selector)
continue
log.info("%s create/update TXT record for %s.", "Would" if dry_run else "Will", name)
if dry_run:
continue
lookup = requests.get(
f"https://api.cloudflare.com/client/v4/zones/{zone_id}/dns_records",
headers={"Authorization": f"Bearer {api_token}"},
params={"type": "TXT", "name": name},
timeout=30,
)
lookup.raise_for_status()
existing = lookup.json().get("result", [])
payload = {"type": "TXT", "name": name, "content": new_record_value, "ttl": ttl}
if existing:
record_id = existing[0]["id"]
resp = requests.put(
f"https://api.cloudflare.com/client/v4/zones/{zone_id}/dns_records/{record_id}",
headers={"Authorization": f"Bearer {api_token}", "Content-Type": "application/json"},
json=payload, timeout=30,
)
else:
resp = requests.post(
f"https://api.cloudflare.com/client/v4/zones/{zone_id}/dns_records",
headers={"Authorization": f"Bearer {api_token}", "Content-Type": "application/json"},
json=payload, timeout=30,
)
resp.raise_for_status()
log.info("Published DKIM TXT record for %s", name)
if __name__ == "__main__":
run()
/**
* Detect a missing or stale DKIM selector record and repair it via Cloudflare.
* Safe to run on a schedule. Stays in dry run until DRY_RUN=false.
*/
import { pathToFileURL } from "node:url";
const DEFAULT_SELECTORS = ["google", "selector1", "selector2", "k1", "s1"];
export function evaluateDkimSelector(txtAnswers, expectedSelector, expectedPubkeyFragment) {
// Pure decision function. No network, no I/O.
// txtAnswers is the list of TXT strings already resolved for
// selector._domainkey.domain (empty array if NXDOMAIN or no answer).
if (!txtAnswers || txtAnswers.length === 0) {
return { status: "missing", reason: `no TXT record found for selector '${expectedSelector}'` };
}
const value = txtAnswers[0];
if (!value.startsWith("v=DKIM1")) {
return { status: "stale", reason: "record exists but does not start with v=DKIM1" };
}
if (expectedPubkeyFragment && !value.includes(expectedPubkeyFragment)) {
return { status: "stale", reason: "record exists but public key does not match the expected key" };
}
return { status: "ok", reason: "record is present and matches expectations" };
}
export async function run() {
// Imported lazily so the pure function above can be tested with no
// network modules touched at all.
const dns = await import("node:dns");
const resolvePromises = dns.promises;
const domain = process.env.DNS_DOMAIN;
const selectors = (process.env.DKIM_SELECTORS || DEFAULT_SELECTORS.join(","))
.split(",").map((s) => s.trim()).filter(Boolean);
const expectedPubkeyFragment = process.env.DKIM_PUBKEY_FRAGMENT || null;
const newRecordValue = process.env.DKIM_RECORD_VALUE;
const ttl = Number(process.env.RECORD_TTL || 3600);
const dryRun = (process.env.DRY_RUN || "true").toLowerCase() === "true";
const zoneId = process.env.CLOUDFLARE_ZONE_ID;
const apiToken = process.env.CLOUDFLARE_API_TOKEN;
const resolverIps = ["8.8.8.8", "1.1.1.1"];
for (const selector of selectors) {
const name = `${selector}._domainkey.${domain}`;
const results = {};
for (const ip of resolverIps) {
const resolver = new dns.promises.Resolver();
resolver.setServers([ip]);
try {
const records = await resolver.resolveTxt(name);
results[ip] = records.map((chunks) => chunks.join(""));
} catch (err) {
if (err.code === "ENOTFOUND" || err.code === "ENODATA") {
results[ip] = [];
} else {
throw err;
}
}
}
const txtAnswers = results["8.8.8.8"] || [];
const outcome = evaluateDkimSelector(txtAnswers, selector, expectedPubkeyFragment);
console.log(`Selector ${selector} (${name}): ${outcome.status} -> ${outcome.reason}`);
if (JSON.stringify(results["8.8.8.8"]) !== JSON.stringify(results["1.1.1.1"])) {
console.warn(`Selector ${selector} disagrees between resolvers, likely propagation in progress`);
}
if (outcome.status === "ok") continue;
if (!newRecordValue) {
console.log(`No DKIM_RECORD_VALUE set, skipping repair for ${selector}.`);
continue;
}
console.log(`${dryRun ? "Would" : "Will"} create/update TXT record for ${name}.`);
if (dryRun) continue;
const lookupRes = await fetch(
`https://api.cloudflare.com/client/v4/zones/${zoneId}/dns_records?type=TXT&name=${encodeURIComponent(name)}`,
{ headers: { Authorization: `Bearer ${apiToken}` } },
);
if (!lookupRes.ok) throw new Error(`Cloudflare lookup returned ${lookupRes.status}`);
const lookupBody = await lookupRes.json();
const existing = lookupBody.result || [];
const payload = { type: "TXT", name, content: newRecordValue, ttl };
const url = existing.length
? `https://api.cloudflare.com/client/v4/zones/${zoneId}/dns_records/${existing[0].id}`
: `https://api.cloudflare.com/client/v4/zones/${zoneId}/dns_records`;
const method = existing.length ? "PUT" : "POST";
const res = await fetch(url, {
method,
headers: { Authorization: `Bearer ${apiToken}`, "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!res.ok) throw new Error(`Cloudflare API returned ${res.status}`);
console.log(`Published DKIM TXT record for ${name}`);
}
}
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 evaluator is the part worth testing on its own, because it decides whether a selector is really missing, stale, or fine. It takes a plain list of strings and two strings, so the test needs no network and no DNS library at all.
from dkim_selector_check import evaluate_dkim_selector
def test_missing_when_no_answers():
result = evaluate_dkim_selector([], "google")
assert result["status"] == "missing"
def test_stale_when_not_dkim1():
result = evaluate_dkim_selector(["v=spf1 include:_spf.example.com ~all"], "google")
assert result["status"] == "stale"
def test_stale_when_pubkey_does_not_match():
result = evaluate_dkim_selector(["v=DKIM1; k=rsa; p=AAA123"], "google", expected_pubkey_fragment="ZZZ999")
assert result["status"] == "stale"
def test_ok_when_record_matches():
result = evaluate_dkim_selector(["v=DKIM1; k=rsa; p=AAA123"], "google", expected_pubkey_fragment="AAA123")
assert result["status"] == "ok"
import { test } from "node:test";
import assert from "node:assert/strict";
import { evaluateDkimSelector } from "./dkim-selector-check.js";
test("missing when no answers", () => {
const result = evaluateDkimSelector([], "google");
assert.equal(result.status, "missing");
});
test("stale when not dkim1", () => {
const result = evaluateDkimSelector(["v=spf1 include:_spf.example.com ~all"], "google");
assert.equal(result.status, "stale");
});
test("stale when pubkey does not match", () => {
const result = evaluateDkimSelector(["v=DKIM1; k=rsa; p=AAA123"], "google", "ZZZ999");
assert.equal(result.status, "stale");
});
test("ok when record matches", () => {
const result = evaluateDkimSelector(["v=DKIM1; k=rsa; p=AAA123"], "google", "AAA123");
assert.equal(result.status, "ok");
});
Case studies
The migration that turned on DKIM but never published the key
A team moved from one email provider to another and enabled DKIM signing inside the new platform's dashboard the same day. The platform started signing immediately. Nobody had copied the TXT record into DNS yet, because that step was on a separate checklist owned by a different person.
For a week, every outbound message carried a DKIM signature that no receiver could check, quietly weakening deliverability. A single dig +short TXT selector1._domainkey.example.com came back empty, confirmed the record had simply never been created, and publishing it fixed every future message.
The rotation that left the new selector unpublished
An admin rotated DKIM keys for security reasons and the mail platform switched to signing with a new selector automatically. The old selector's record was still live and correct, but it was no longer the one being used, and the new selector's record had not been added anywhere.
Checking the signature headers on recent mail showed the new selector name, and querying it directly showed NXDOMAIN. Publishing the new selector's TXT record, copied fresh from the platform's DKIM page, restored a clean pass on the next test send.
Once the record is published correctly, dig +short TXT selector._domainkey.example.com from both 8.8.8.8 and 1.1.1.1 returns the identical v=DKIM1; k=rsa; p=... string, and a real test message shows dkim=pass header.i=@example.com in its Authentication-Results header. A checker script like the one above catches the next time a selector gets turned on in a mail platform before its DNS record exists.
FAQ
Why does DKIM show none or permfail when my mail platform is signing every message?
The DKIM signature names a selector, and receivers must fetch a TXT record at selector._domainkey.yourdomain.com to get the public key. If that record was never published, was published at the wrong host, or has not propagated yet, verifiers get NXDOMAIN or no answer and cannot check the signature, so DKIM comes back none or permfail even though signing itself is working.
How do I find out which selector my mail platform is using?
Look at a DKIM-Signature header in a message you sent and read the s= tag, for example s=google or s=s1. That value is the selector. You can also check your mail platform's admin panel, which usually shows the exact selector and TXT record it expects under the DKIM or email authentication settings.
I just published the DKIM record. Why does it still fail?
New DNS records need time to propagate, and different resolvers cache independently. Query at least two public resolvers directly, such as 8.8.8.8 and 1.1.1.1. If one returns the record and the other does not yet, it is propagation catching up, not a wrong record. Give it the record's TTL in minutes and check again.
Related field notes
Citations
On the problem:
- RFC 6376: DomainKeys Identified Mail (DKIM) Signatures. datatracker.ietf.org/doc/html/rfc6376
- DNSimple Help: verify DKIM with dig and online tools. support.dnsimple.com/articles/verify-dkim
- Google Workspace Help: troubleshoot DKIM issues. support.google.com/a/answer/11612790
On the solution:
- Google Workspace Help: set up DKIM. support.google.com/a/answer/174124
- Cloudflare API: create a DNS record. developers.cloudflare.com/api/resources/dns/subresources/records/methods/create
- Cloudflare DNS docs: manage DNS records. developers.cloudflare.com/dns/manage-dns-records/how-to/create-dns-records
Stuck on a tricky one?
If you have a DNS, email deliverability, or certificate 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 DKIM check?
If this saved you a confusing afternoon chasing a deliverability drop, 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