Diagnostic Email Authentication
DKIM TXT record malformed or wrapped in quotes
The DKIM record is there. Dig returns something. But every outbound message still fails signature verification. Nine times out of ten, someone copy-pasted the public key into the DNS host's web form and carried along a stray quote mark, a broken split, or a hidden space that quietly corrupts the key. Here is why that happens and how to fix it.
A DKIM public key is usually 300 to 800 or more characters long, but a single DNS TXT string can only hold 255 characters, so DNS publishes long keys as several quoted strings inside one TXT record that get joined back together automatically. When the key is copy-pasted by hand into a DNS host's web form, it is easy to paste the literal quote characters along with it, to split the key across two separate TXT records instead of one record with multiple strings, or to leave a stray space or line break inside the base64 body. Any of these breaks the p= value so it no longer decodes as a valid key, even though the record still resolves and looks present. The fix is to delete the broken record and republish the exact key as plain text with no manual quotes, letting the DNS host handle the splitting. Full commands, checks, and a validator script are below.
The problem in plain words
A DKIM record holds a public key written in base64 text. That text is long, usually longer than a single DNS TXT string is allowed to be. So the DNS format lets you write the key as more than one quoted chunk inside one TXT record, and resolvers glue those chunks back together when they read it.
The trouble starts at the copy-paste step. A mail provider's setup page shows you the key as one long line. When that gets pasted into a DNS host's text field, it is common to accidentally include the quote marks that were only meant to show where the text starts and ends, to split the key into two records because a legacy panel forced a length limit, or to pick up a space or newline that a text editor or word processor quietly inserted. The record still exists and dig still returns a value. It simply is not the key anymore. It is the key plus some garbage characters, so it fails to decode, and every signature check fails silently.
Why it happens
- The DNS host's web form shows an example value already wrapped in quote marks, and a person pastes the entire example including those quote characters into the value field.
- A legacy DNS panel enforces a 255-character limit per field and forces the key into two separate TXT records at the same name instead of one record with two quoted strings.
- The key was copied through a word processor, chat app, or email client that silently inserted a line break or smart-quote character into the middle of the base64 body.
- A trailing space was left after copying, or the key was copied in two pieces and joined with an extra space in between.
- An old and a new DKIM record both exist at the same selector name, so the resolver returns more than one TXT answer and the mail platform reads the wrong one.
This is one of the most common DKIM failures because the record is never actually missing, which is what makes it confusing. Everything looks configured. The corruption is invisible until someone actually tries to base64-decode the value.
A record that resolves is not the same as a record that works. DKIM verification depends on the exact bytes inside the p= value decoding cleanly as base64 into a valid public key. A single stray quote character, an extra space, or a record split in the wrong place is enough to break that decode, even though dig will happily print the value back to you looking almost right.
The fix, as a flow
Pull the DKIM value straight from the mail provider's setup page, confirm it has no quote marks or line breaks in it, then paste that plain string into the DNS host's TXT value field as one continuous piece of text. Let the DNS host handle the 255-character splitting internally rather than doing it by hand, then decode the published record to confirm it is really a valid key.
How to fix it
Look at what is actually published right now
Query the selector and strip the quotes to see the raw characters. A healthy record shows one continuous base64 body with no quote or backslash characters left inside it. A broken one shows a stray " or \ in the middle of the key, a space where there should not be one, or two separate TXT answers at the same name.
dig +short TXT selector._domainkey.example.com
dig +short TXT selector._domainkey.example.com | tr -d '"'
dig selector._domainkey.example.com TXT +noall +answer
nslookup -type=TXT selector._domainkey.example.com
Confirm the base64 actually decodes
Take the p= value from the record and try to decode it. If Python raises a base64 error, or openssl says it cannot load the key, the record is corrupted, no matter how correct it looks in the DNS panel.
python3 -c "import base64; base64.b64decode('PASTE_P_VALUE_HERE')"
# binascii.Error means the base64 body is broken
echo "PASTE_P_VALUE_HERE" | base64 -d | openssl rsa -pubin -inform DER -noout -text
# "unable to load Public Key" confirms it is malformed
Get the exact value straight from the mail provider
Go back to the mail provider's DKIM setup page, whether that is Google Workspace, Microsoft 365, or SendGrid, and copy the value it shows. It should look like one continuous string with no quote marks of its own, starting with v=DKIM1.
v=DKIM1; k=rsa; p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA...restofkey
; paste exactly this, with no manually typed quote marks
; and no line breaks anywhere inside it
Remove the broken record and republish it in one record
Delete the corrupted TXT record and any duplicate or old record at the same selector name first. Then create one new TXT record at that selector name with the clean value pasted as plain text. Modern hosts like Cloudflare, Route 53, and Google Domains split anything over 255 characters into the correct multi-string wire format automatically, so you never need to add quote characters yourself.
; what a correct multi-string record looks like on the wire
selector._domainkey.example.com 3600 IN TXT "v=DKIM1; k=rsa; p=MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQC..." "...restofkey"
; never create two separate TXT records for the two halves like this:
; selector._domainkey.example.com 3600 IN TXT "v=DKIM1; k=rsa; p=MIGfMA0GCSq..."
; selector._domainkey.example.com 3600 IN TXT "...restofkey"
If a legacy panel forces manual chunking, split only at whole boundaries
Some older DNS panels still require you to paste each 255-character chunk into its own quoted field within the same record. If that is the only option, split at whole 255-character boundaries, wrap each chunk in its own quotes, and put a single space between the closing and opening quotes, all inside the one TXT record, not across two records.
"v=DKIM1; k=rsa; p=MIIBIjANBgkq...255chars..." "...remaining chars..."
How to check it worked
Re-query the selector, strip the quotes, and confirm the base64 decodes cleanly with a byte length that matches a real RSA key, roughly 140 bytes for a 1024-bit key or 270 bytes for a 2048-bit key. Then send a real test message and read the Authentication-Results header on the received copy.
dig +short TXT selector._domainkey.example.com
# a good answer looks like one or more clean quoted chunks, for example:
# "v=DKIM1; k=rsa; p=MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQC..." "...restofkey"
python3 -c "import base64; print(len(base64.b64decode('PASTE_P_VALUE_HERE')))"
# should print a byte length like 140 (1024-bit) or 270 (2048-bit), not raise binascii.Error
# 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 resolves a selector's TXT record, joins the character-strings the way a resolver would, checks for stray quote or backslash artifacts, and confirms the p= value actually base64-decodes into a valid key. When told to repair, it publishes the corrected value through the Cloudflare API.
View this code on GitHub Full runnable folder with tests in the dns-fixes repo.
"""Detect a malformed DKIM TXT record and repair it via Cloudflare.
Safe to run on a schedule. Stays in dry run until DRY_RUN=false.
"""
import os
import re
import base64
import logging
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("dkim_txt_check")
TAG_RE = re.compile(r"(v|k|p)=([^;]*)")
def validate_dkim_txt(strings: list) -> dict:
"""Pure decision function. No network, no I/O.
strings is the list of character-strings dnspython returns for the
TXT RRset at a selector, e.g. ['v=DKIM1; k=rsa; p=MIIBIjq...', '...rest'].
"""
if strings is None or len(strings) == 0:
return {"valid": False, "reason": "empty_key", "key_bytes": None}
joined = "".join(strings)
if '"' in joined or "\\" in joined:
return {"valid": False, "reason": "embedded_quotes", "key_bytes": None}
tags = dict(TAG_RE.findall(joined))
p_value = tags.get("p", "").strip()
if not p_value:
return {"valid": False, "reason": "empty_key", "key_bytes": None}
if " " in p_value or "\n" in p_value or "\t" in p_value:
return {"valid": False, "reason": "embedded_quotes", "key_bytes": None}
try:
key_bytes = base64.b64decode(p_value, validate=True)
except Exception:
return {"valid": False, "reason": "not_base64", "key_bytes": None}
return {"valid": True, "reason": "ok", "key_bytes": len(key_bytes)}
def run():
# Imported lazily so the pure function above can be tested with no
# network or crypto libraries installed at all.
import dns.resolver
import requests
from cryptography.hazmat.primitives.serialization import load_der_public_key
domain = os.environ["DNS_DOMAIN"]
selector = os.environ.get("DKIM_SELECTOR", "selector1")
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"]
name = f"{selector}._domainkey.{domain}"
try:
answer = dns.resolver.resolve(name, "TXT")
rrsets = []
for rdata in answer:
strings = [s.decode() if isinstance(s, bytes) else s for s in rdata.strings]
rrsets.append(strings)
except (dns.resolver.NXDOMAIN, dns.resolver.NoAnswer):
rrsets = []
if len(rrsets) > 1:
log.warning("Selector %s has %d separate TXT RRsets at the same name (multiple_txt_records)", selector, len(rrsets))
outcome = {"valid": False, "reason": "multiple_txt_records", "key_bytes": None}
elif len(rrsets) == 0:
outcome = {"valid": False, "reason": "empty_key", "key_bytes": None}
else:
outcome = validate_dkim_txt(rrsets[0])
log.info("Selector %s (%s): valid=%s reason=%s key_bytes=%s", selector, name, outcome["valid"], outcome["reason"], outcome["key_bytes"])
if outcome["valid"]:
# Belt and suspenders: also confirm cryptography can load the key.
p_value = dict(TAG_RE.findall("".join(rrsets[0]))).get("p", "").strip()
try:
load_der_public_key(base64.b64decode(p_value))
log.info("Key for %s loads as a valid public key.", name)
except Exception as exc:
log.warning("Key for %s decoded as base64 but did not load as a public key: %s", name, exc)
return
if not new_record_value:
log.info("No DKIM_RECORD_VALUE set, skipping repair for %s.", selector)
return
log.info("%s remove the broken record(s) and republish %s.", "Would" if dry_run else "Will", name)
if dry_run:
return
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", [])
for record in existing:
del_resp = requests.delete(
f"https://api.cloudflare.com/client/v4/zones/{zone_id}/dns_records/{record['id']}",
headers={"Authorization": f"Bearer {api_token}"},
timeout=30,
)
del_resp.raise_for_status()
log.info("Deleted broken TXT record %s at %s", record["id"], name)
create_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={"type": "TXT", "name": name, "content": new_record_value, "ttl": ttl},
timeout=30,
)
create_resp.raise_for_status()
log.info("Published corrected DKIM TXT record for %s", name)
if __name__ == "__main__":
run()
/**
* Detect a malformed DKIM TXT 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 TAG_RE = /(v|k|p)=([^;]*)/g;
export function validateDkimTxt(strings) {
// Pure decision function. No network, no I/O.
// strings is the list of character-strings the built-in dns module
// returns for the TXT RRset at a selector, e.g.
// ['v=DKIM1; k=rsa; p=MIIBIjq...', '...rest'].
if (!strings || strings.length === 0) {
return { valid: false, reason: "empty_key", keyBytes: null };
}
const joined = strings.join("");
if (joined.includes('"') || joined.includes("\\")) {
return { valid: false, reason: "embedded_quotes", keyBytes: null };
}
const tags = {};
for (const match of joined.matchAll(TAG_RE)) {
tags[match[1]] = match[2];
}
const pValue = (tags.p || "").trim();
if (!pValue) {
return { valid: false, reason: "empty_key", keyBytes: null };
}
if (/[\s]/.test(pValue)) {
return { valid: false, reason: "embedded_quotes", keyBytes: null };
}
try {
const keyBytes = Buffer.from(pValue, "base64");
if (keyBytes.toString("base64").replace(/=+$/, "") !== pValue.replace(/=+$/, "")) {
return { valid: false, reason: "not_base64", keyBytes: null };
}
return { valid: true, reason: "ok", keyBytes: keyBytes.length };
} catch {
return { valid: false, reason: "not_base64", keyBytes: null };
}
}
export async function run() {
// Imported lazily so the pure function above can be tested with no
// network or crypto modules touched at all.
const dns = await import("node:dns");
const crypto = await import("node:crypto");
const domain = process.env.DNS_DOMAIN;
const selector = process.env.DKIM_SELECTOR || "selector1";
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 name = `${selector}._domainkey.${domain}`;
let rrsets = [];
try {
const records = await dns.promises.resolveTxt(name);
rrsets = records;
} catch (err) {
if (err.code !== "ENOTFOUND" && err.code !== "ENODATA") throw err;
}
let outcome;
if (rrsets.length > 1) {
console.warn(`Selector ${selector} has ${rrsets.length} separate TXT RRsets at the same name (multiple_txt_records)`);
outcome = { valid: false, reason: "multiple_txt_records", keyBytes: null };
} else if (rrsets.length === 0) {
outcome = { valid: false, reason: "empty_key", keyBytes: null };
} else {
outcome = validateDkimTxt(rrsets[0]);
}
console.log(`Selector ${selector} (${name}): valid=${outcome.valid} reason=${outcome.reason} keyBytes=${outcome.keyBytes}`);
if (outcome.valid) {
const joined = rrsets[0].join("");
const match = /p=([^;]*)/.exec(joined);
const pValue = match ? match[1].trim() : "";
try {
crypto.createPublicKey({
key: Buffer.from(pValue, "base64"),
format: "der",
type: "spki",
});
console.log(`Key for ${name} loads as a valid public key.`);
} catch (exc) {
console.warn(`Key for ${name} decoded as base64 but did not load as a public key: ${exc.message}`);
}
return;
}
if (!newRecordValue) {
console.log(`No DKIM_RECORD_VALUE set, skipping repair for ${selector}.`);
return;
}
console.log(`${dryRun ? "Would" : "Will"} remove the broken record(s) and republish ${name}.`);
if (dryRun) return;
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 || [];
for (const record of existing) {
const delRes = await fetch(
`https://api.cloudflare.com/client/v4/zones/${zoneId}/dns_records/${record.id}`,
{ method: "DELETE", headers: { Authorization: `Bearer ${apiToken}` } },
);
if (!delRes.ok) throw new Error(`Cloudflare delete returned ${delRes.status}`);
console.log(`Deleted broken TXT record ${record.id} at ${name}`);
}
const createRes = await fetch(
`https://api.cloudflare.com/client/v4/zones/${zoneId}/dns_records`,
{
method: "POST",
headers: { Authorization: `Bearer ${apiToken}`, "Content-Type": "application/json" },
body: JSON.stringify({ type: "TXT", name, content: newRecordValue, ttl }),
},
);
if (!createRes.ok) throw new Error(`Cloudflare create returned ${createRes.status}`);
console.log(`Published corrected 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 validator is the part worth testing on its own, because it decides whether a key is genuinely broken or fine. It takes a plain list of strings and returns a plain result, so the test needs no network, no DNS library, and no real key at all.
from dkim_txt_check import validate_dkim_txt
def test_empty_when_no_strings():
result = validate_dkim_txt([])
assert result["valid"] is False
assert result["reason"] == "empty_key"
def test_embedded_quotes_flagged():
result = validate_dkim_txt(['"v=DKIM1; k=rsa; p=AAA123"'])
assert result["valid"] is False
assert result["reason"] == "embedded_quotes"
def test_not_base64_flagged():
result = validate_dkim_txt(["v=DKIM1; k=rsa; p=not-valid-base64!!!"])
assert result["valid"] is False
assert result["reason"] == "not_base64"
def test_valid_key_decodes():
result = validate_dkim_txt(["v=DKIM1; k=rsa; p=aGVsbG93b3JsZA=="])
assert result["valid"] is True
assert result["reason"] == "ok"
assert result["key_bytes"] == 10
import { test } from "node:test";
import assert from "node:assert/strict";
import { validateDkimTxt } from "./dkim-txt-check.js";
test("empty when no strings", () => {
const result = validateDkimTxt([]);
assert.equal(result.valid, false);
assert.equal(result.reason, "empty_key");
});
test("embedded quotes flagged", () => {
const result = validateDkimTxt(['"v=DKIM1; k=rsa; p=AAA123"']);
assert.equal(result.valid, false);
assert.equal(result.reason, "embedded_quotes");
});
test("not base64 flagged", () => {
const result = validateDkimTxt(["v=DKIM1; k=rsa; p=not-valid-base64!!!"]);
assert.equal(result.valid, false);
assert.equal(result.reason, "not_base64");
});
test("valid key decodes", () => {
const result = validateDkimTxt(["v=DKIM1; k=rsa; p=aGVsbG93b3JsZA=="]);
assert.equal(result.valid, true);
assert.equal(result.reason, "ok");
assert.equal(result.keyBytes, 10);
});
Case studies
The setup page example that got pasted whole
An admin followed a mail provider's DKIM setup page that showed the value already wrapped in quote marks as an example of the expected format. The admin copied the entire example, quote marks included, into the DNS host's TXT value field and saved it.
The record resolved fine and looked correct in the dashboard. Every message still came back dkim=fail. Running dig +short TXT selector._domainkey.example.com | tr -d '"' still showed a literal quote character sitting inside the base64 body. Removing the record and pasting only the raw value, with no quote marks typed in, fixed it on the next send.
The 2048-bit key split across two TXT records
A store moved to a 2048-bit DKIM key for better security. The DNS panel in use only accepted 255 characters per record, so whoever added it created two separate TXT records at the same selector name, one for each half of the key.
A dig lookup showed two answers at the same name instead of one multi-string record, and the mail platform's verifier only ever read one of them, which decoded as a truncated, invalid key. Deleting both records and creating one TXT record containing both quoted chunks together resolved it immediately.
Once the record is republished correctly, dig +short TXT selector._domainkey.example.com returns one or more quoted strings with no stray quote or backslash characters inside the base64, and decoding the p= value with Python or openssl succeeds without error. A real test message to Gmail or mail-tester.com then shows dkim=pass in the Authentication-Results header. A validator script like the one above catches the next time a key gets pasted with a hidden character before it ever reaches production mail.
FAQ
Why does my DKIM record look present but still fail signature checks?
The record resolves, but the p= value inside it does not decode as a valid public key. This usually happens when someone pastes the literal quote characters from a setup page into the DNS host's field, splits the key across two separate TXT records instead of one record with multiple strings, or leaves a stray space or newline inside the base64 body. Any of these breaks the base64 so it can no longer be decoded into a usable key.
Why is a DKIM key split into more than one quoted string at all?
A single DNS TXT character-string is capped at 255 characters by RFC 1035, but a DKIM public key in base64 is usually 300 to 800 or more characters long. The fix built into the DNS protocol is to publish the key as several quoted strings inside one TXT record, and resolvers concatenate those strings back together automatically. Problems start when people manually recreate that split and get it wrong.
How do I confirm a fixed DKIM record actually works?
Query the selector with dig, strip the quotes, and confirm the p= value base64-decodes without error to a byte length consistent with a 1024-bit or 2048-bit key. Then send a real test email to a Gmail address or mail-tester.com and confirm the Authentication-Results header shows dkim=pass.
Related field notes
Citations
On the problem:
- RFC 1035: Domain Names, Implementation and Specification, section 3.3.14 on the 255-byte TXT character-string limit. rfc-editor.org/rfc/rfc1035
- RFC 6376: DomainKeys Identified Mail (DKIM) Signatures. rfc-editor.org/rfc/rfc6376
- AWS re:Post: resolve the CharacterStringTooLong error from a DNS TXT record. repost.aws/knowledge-center/route53-resolve-dkim-text-record-error
On the solution:
- Cloudflare: what is a DNS DKIM record. cloudflare.com/learning/dns/dns-records/dns-dkim-record
- DMARCPal: DKIM record too long for DNS, how to split 2048-bit keys correctly. dmarcpal.com/learn/dkim-record-too-long-for-dns-split-2048-bit-key
- Cloudflare API documentation: list DNS records for a zone. developers.cloudflare.com/api/operations/dns-records-for-a-zone-list-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