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.

dig and Cloudflare API Python and Node.js Fixable through the DNS API
A blue envelope
Photo by Bianca Ackermann on Unsplash
The short answer

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.

Mail platform signs with selector Receiver looks up selector._domainkey TXT record missing No public key NXDOMAIN or empty dkim=none or permfail
The signature itself is fine. The receiver simply has nowhere to fetch the public key, so it cannot validate what your platform signed.

Why it happens

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.

The key insight

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.

Get selector + key from mail platform Confirm zone dig NS yourdomain.com Publish TXT record at selector._domainkey Both resolvers agree? yes no, wait for TTL Send test email check dkim=pass
Confirm the zone that is actually authoritative before publishing anything, then verify the record agrees across resolvers before trusting a test send.

How to fix it

1

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.

Terminal
check (shell)
dig +short TXT google._domainkey.example.com
dig selector1._domainkey.example.com TXT +noall +comment
dig +trace TXT s1._domainkey.example.com
2

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.

Terminal
check-resolvers (shell)
dig @8.8.8.8 TXT google._domainkey.example.com
dig @1.1.1.1 TXT google._domainkey.example.com
3

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.

Terminal
check-ns (shell)
dig NS example.com +short
4

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.

DNS record
zone record
; 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..."
5

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.

Terminal
create-record (shell)
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.

Terminal
verify (shell)
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.

dkim_selector_check.py
"""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()
dkim-selector-check.js
/**
 * 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.

test_dkim.py
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"
dkim-selector-check.test.js
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

Provider switch

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.

Key rotation

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.

What good looks like

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:

  1. RFC 6376: DomainKeys Identified Mail (DKIM) Signatures. datatracker.ietf.org/doc/html/rfc6376
  2. DNSimple Help: verify DKIM with dig and online tools. support.dnsimple.com/articles/verify-dkim
  3. Google Workspace Help: troubleshoot DKIM issues. support.google.com/a/answer/11612790

On the solution:

  1. Google Workspace Help: set up DKIM. support.google.com/a/answer/174124
  2. Cloudflare API: create a DNS record. developers.cloudflare.com/api/resources/dns/subresources/records/methods/create
  3. 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.

Contact me on LinkedIn

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

Back to all DNS and Domains field notes