Reconciler Email Authentication
Required DKIM CNAME selectors not published
Microsoft 365 does not give you a DKIM key to paste into a TXT record. It gives you two hostnames and asks you to publish two CNAME records that point at them, so it can rotate its signing keys behind the scenes. If one CNAME is missing, wrong, or was created as a TXT record by mistake, Microsoft cannot finish rotating, DKIM signing breaks, and with DMARC turned up, real mail can start bouncing.
Microsoft 365 signs mail with keys it rotates on its own, and it needs two CNAME records, selector1._domainkey and selector2._domainkey, pointing at a target hostname on its infrastructure to make that rotation work. If either selector is missing, points at the wrong target, or was published as a TXT record instead of a CNAME, rotation stalls and DKIM signatures stop validating. Check both selectors with dig, republish the exact CNAME targets your tenant was issued (get them from Get-DkimSigningConfig), delete any conflicting TXT record first, then enable DKIM signing. Full code and tests are below.
The problem in plain words
Most DKIM setups ask you to copy a public key into a TXT record. Microsoft 365 works differently. It holds the actual signing keys on its own servers and rotates them on its own schedule, without ever asking you to touch DNS again after the first setup. To make that work, it asks you to publish two CNAME records that simply point your domain's DKIM selector names at a hostname it controls, something like selector1-yourdomain-com._domainkey.yourdomain.onmicrosoft.com.
Those two selectors, selector1 and selector2, exist so Microsoft can flip between them during a rotation without any downtime. When it is time to rotate, it starts signing with the other selector. If that other selector's CNAME was never published, was mistyped, or was accidentally created as a TXT record instead of a CNAME, the rotation has nowhere to land. DKIM signing breaks the moment Microsoft switches to the selector that does not resolve, and if your domain's DMARC policy is set to quarantine or reject, mail can start failing delivery for a reason that has nothing to do with the message content.
Why it happens
- Only one of the two CNAME records was copied in during setup, so
selector1resolves fine butselector2was never created, or the reverse. - The record was created as a TXT record because whoever set it up was used to other DKIM providers that publish a raw key, and pasted the target string into a TXT value instead of using it as a CNAME target.
- The target hostname was copied from an old blog post or a different tenant's example instead of the exact value Microsoft issued for this domain, since the target string is tenant specific and includes a partition segment that differs between tenants.
- DKIM signing was enabled in the Defender portal before both CNAMEs finished propagating, so the initial check passed on a cached answer and nobody rechecked after DNS updated.
Microsoft's own documentation describes this exact CNAME-to-CNAME setup and the two-selector rotation model. It is intentional, not a workaround, and it only works end to end when both selectors resolve correctly at all times.
With most DKIM setups, a missing record just means signing was never turned on. With Microsoft 365, both selectors are supposed to exist at all times, even though only one is actively signing mail at any moment. The inactive selector's CNAME is what makes the next rotation possible. Treat both as required, not just the one Microsoft is currently using.
The fix, as a flow
The fix is to query both selector names, confirm each one is a CNAME and that the target matches what Microsoft issued for this specific tenant, and republish anything that is missing or wrong. If a name already holds a TXT record, that record has to be deleted first, since a name cannot hold both a CNAME and a TXT record at once. Once both CNAMEs resolve correctly, DKIM signing can be enabled or left enabled with confidence that the next rotation will not break anything.
How to fix it
Check both selectors right now
Query each selector for a CNAME answer. A healthy tenant returns a target hostname for both. Anything else, including a TXT answer at that name, is the problem.
dig +short CNAME selector1._domainkey.yourdomain.com
dig +short CNAME selector2._domainkey.yourdomain.com
# also check whether either name was mistakenly created as TXT
dig +short TXT selector1._domainkey.yourdomain.com
dig +short TXT selector2._domainkey.yourdomain.com
# a name cannot hold both a CNAME and a TXT record, so a TXT answer here
# means the CNAME was never actually created
dig selector2._domainkey.yourdomain.com CNAME
# look at the header line: ANSWER: 0 means nothing is published at all
Get the exact per-tenant target strings
Do not reuse another domain's example values. Microsoft's CNAME targets are tenant specific and include a partition character, so an older blog post's example almost never matches your tenant. Pull the real values from Exchange Online PowerShell or the Defender portal.
Get-DkimSigningConfig -Identity yourdomain.com | Format-List Selector1CNAME,Selector2CNAME,Status,Selector1KeySize
# Selector1CNAME and Selector2CNAME are the exact hostnames you must
# point your CNAME records at. Copy them exactly, do not retype them.
Remove any conflicting TXT record first
If a selector name currently holds a TXT record, delete it before adding the CNAME. A single name cannot hold both record types at once, so the CNAME will not save until the TXT record is gone.
Publish both CNAME records exactly as issued
Add both records at your DNS host, using the exact target strings from step 2. Keep the proxy status off for these records, since they only need to resolve for mail systems, not serve web traffic.
Type: CNAME Name: selector1._domainkey Target: selector1-yourdomain-com._domainkey.yourdomain.onmicrosoft.com TTL: Auto Proxy status: DNS only (grey cloud)
Type: CNAME Name: selector2._domainkey Target: selector2-yourdomain-com._domainkey.yourdomain.onmicrosoft.com TTL: Auto Proxy status: DNS only (grey cloud)
Enable DKIM signing once both CNAMEs resolve
After both records are live, turn signing on in the Defender portal, or with PowerShell. Enabling it before the CNAMEs resolve is what causes a rotation error later, so confirm DNS first.
Set-DkimSigningConfig -Identity yourdomain.com -Enabled $true
The CNAME target Microsoft issues is specific to your tenant. Always pull the real Selector1CNAME and Selector2CNAME values for your own domain rather than trusting a value shown in a guide, including this one.
How to check it worked
Re-run the selector queries, confirm the provider shows signing as active, then send a real test message and read the headers.
dig +short CNAME selector1._domainkey.yourdomain.com
dig +short CNAME selector2._domainkey.yourdomain.com
# expect: both return a hostname target, e.g.
# selector1-yourdomain-com._domainkey.yourdomain.onmicrosoft.com.
# selector2-yourdomain-com._domainkey.yourdomain.onmicrosoft.com.
dig @1.1.1.1 +short CNAME selector2._domainkey.yourdomain.com
dig +trace selector2._domainkey.yourdomain.com
# confirms the record at a public resolver too, ruling out propagation lag
dig TXT _dmarc.yourdomain.com +short
# confirms a DMARC policy is in place to catch any future regression
Get-DkimSigningConfig -Identity yourdomain.com | Format-List Selector1CNAME,Selector2CNAME,Status,Selector1KeySize
# expect: Status shows DKIM signing enabled/active
# Then send a message to an external mailbox such as Gmail and read the
# Authentication-Results header on the received message. A pass on
# either currently active selector confirms rotation works end to end:
#
# Authentication-Results: mx.google.com;
# dkim=pass header.d=yourdomain.com header.s=selector1
The full code
Here is a small script in each language that resolves both selectors, compares what it finds against your tenant's expected targets, and, when told to, republishes the correct CNAME through the Cloudflare API. It stays in dry run by default so it only reports what it would change.
View this code on GitHub Full runnable folder with tests in the dns-fixes repo.
"""Check that both Microsoft 365 DKIM CNAME selectors are published correctly,
and optionally repair them through the Cloudflare API.
Safe by default. Set DRY_RUN=false to let it write.
Env vars:
DNS_DOMAIN the domain to check, e.g. yourdomain.com
DKIM_SELECTOR1_TARGET expected CNAME target for selector1, from
Get-DkimSigningConfig -Identity yourdomain.com
DKIM_SELECTOR2_TARGET expected CNAME target for selector2
CLOUDFLARE_API_TOKEN only needed for repair
CLOUDFLARE_ZONE_ID only needed for repair
DRY_RUN defaults to "true"
"""
import os
import logging
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("dkim_selector_check")
DNS_DOMAIN = os.environ.get("DNS_DOMAIN", "yourdomain.com")
CLOUDFLARE_API_TOKEN = os.environ.get("CLOUDFLARE_API_TOKEN", "")
CLOUDFLARE_ZONE_ID = os.environ.get("CLOUDFLARE_ZONE_ID", "")
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
CF_API = "https://api.cloudflare.com/client/v4"
def check_dkim_selectors(selector_records: dict, expected_targets: dict) -> list:
"""Pure decision function. No I/O.
selector_records: {"selector1": {"type": "CNAME"|"TXT"|None, "target": str|None}, "selector2": {...}}
expected_targets: {"selector1": "selector1-yourdomain-com._domainkey.yourdomain.onmicrosoft.com", "selector2": "..."}
Returns a list of finding dicts like
{"selector": "selector2", "issue": "missing"|"wrong_type"|"target_mismatch", "found": ..., "expected": ...}
for each selector that fails to resolve as a CNAME to its expected target.
An empty list means healthy.
"""
findings = []
for selector, expected in expected_targets.items():
record = selector_records.get(selector) or {"type": None, "target": None}
rtype = record.get("type")
target = record.get("target")
if rtype is None:
findings.append({"selector": selector, "issue": "missing", "found": None, "expected": expected})
continue
if rtype != "CNAME":
findings.append({"selector": selector, "issue": "wrong_type", "found": rtype, "expected": expected})
continue
if target != expected:
findings.append({"selector": selector, "issue": "target_mismatch", "found": target, "expected": expected})
return findings
def query_selector_records(domain: str) -> dict:
"""Query CNAME (and TXT, to detect a wrong-type conflict) for both selectors."""
import dns.resolver
resolver = dns.resolver.Resolver()
records = {}
for selector in ("selector1", "selector2"):
name = f"{selector}._domainkey.{domain}"
try:
answer = resolver.resolve(name, "CNAME")
records[selector] = {"type": "CNAME", "target": str(answer[0].target).rstrip(".")}
continue
except (dns.resolver.NoAnswer, dns.resolver.NXDOMAIN):
pass
except Exception as exc:
log.warning("CNAME query for %s failed: %s", name, exc)
try:
resolver.resolve(name, "TXT")
records[selector] = {"type": "TXT", "target": None}
except (dns.resolver.NoAnswer, dns.resolver.NXDOMAIN):
records[selector] = {"type": None, "target": None}
except Exception as exc:
log.warning("TXT query for %s failed: %s", name, exc)
records[selector] = {"type": None, "target": None}
return records
def find_conflicting_record_id(domain: str, selector: str, record_type: str):
"""Find an existing record of record_type at the selector name via the Cloudflare API."""
import requests
name = f"{selector}._domainkey.{domain}"
headers = {"Authorization": f"Bearer {CLOUDFLARE_API_TOKEN}"}
url = f"{CF_API}/zones/{CLOUDFLARE_ZONE_ID}/dns_records"
r = requests.get(url, headers=headers, params={"type": record_type, "name": name}, timeout=30)
r.raise_for_status()
result = r.json().get("result", [])
return result[0]["id"] if result else None
def publish_selector_cname(domain: str, selector: str, target: str, conflicting_txt_id: str = None):
"""Delete a conflicting TXT record, if any, then create the correct CNAME."""
import requests
name = f"{selector}._domainkey.{domain}"
headers = {"Authorization": f"Bearer {CLOUDFLARE_API_TOKEN}", "Content-Type": "application/json"}
base = f"{CF_API}/zones/{CLOUDFLARE_ZONE_ID}/dns_records"
if DRY_RUN:
if conflicting_txt_id:
log.info("[dry run] would delete TXT record %s at %s", conflicting_txt_id, name)
log.info("[dry run] would create CNAME %s -> %s", name, target)
return
if conflicting_txt_id:
requests.delete(f"{base}/{conflicting_txt_id}", headers=headers, timeout=30).raise_for_status()
requests.post(
base,
headers=headers,
json={"type": "CNAME", "name": name, "content": target, "proxied": False},
timeout=30,
).raise_for_status()
log.info("Published CNAME %s -> %s", name, target)
def run():
expected_targets = {
"selector1": os.environ.get("DKIM_SELECTOR1_TARGET", ""),
"selector2": os.environ.get("DKIM_SELECTOR2_TARGET", ""),
}
if not all(expected_targets.values()):
log.warning("Set DKIM_SELECTOR1_TARGET and DKIM_SELECTOR2_TARGET from Get-DkimSigningConfig first.")
return
records = query_selector_records(DNS_DOMAIN)
findings = check_dkim_selectors(records, expected_targets)
if not findings:
log.info("Both DKIM selectors are healthy for %s.", DNS_DOMAIN)
return
for finding in findings:
log.warning("selector=%s issue=%s found=%s expected=%s",
finding["selector"], finding["issue"], finding["found"], finding["expected"])
if not (CLOUDFLARE_API_TOKEN and CLOUDFLARE_ZONE_ID):
log.warning("Issues found but no Cloudflare credentials set. Skipping repair.")
return
for finding in findings:
selector = finding["selector"]
conflicting_txt_id = None
if finding["issue"] == "wrong_type":
conflicting_txt_id = find_conflicting_record_id(DNS_DOMAIN, selector, "TXT")
publish_selector_cname(DNS_DOMAIN, selector, finding["expected"], conflicting_txt_id)
if __name__ == "__main__":
run()
/**
* Check that both Microsoft 365 DKIM CNAME selectors are published correctly,
* and optionally repair them through the Cloudflare API.
*
* Safe by default. Set DRY_RUN=false to let it write.
*
* Env vars:
* DNS_DOMAIN the domain to check, e.g. yourdomain.com
* DKIM_SELECTOR1_TARGET expected CNAME target for selector1, from
* Get-DkimSigningConfig -Identity yourdomain.com
* DKIM_SELECTOR2_TARGET expected CNAME target for selector2
* CLOUDFLARE_API_TOKEN only needed for repair
* CLOUDFLARE_ZONE_ID only needed for repair
* DRY_RUN defaults to "true"
*/
import { pathToFileURL } from "node:url";
const DNS_DOMAIN = process.env.DNS_DOMAIN || "yourdomain.com";
const CLOUDFLARE_API_TOKEN = process.env.CLOUDFLARE_API_TOKEN || "";
const CLOUDFLARE_ZONE_ID = process.env.CLOUDFLARE_ZONE_ID || "";
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
const CF_API = "https://api.cloudflare.com/client/v4";
/**
* Pure decision function. No I/O.
*
* selectorRecords: { selector1: { type: "CNAME"|"TXT"|null, target: string|null }, selector2: {...} }
* expectedTargets: { selector1: "selector1-yourdomain-com._domainkey.yourdomain.onmicrosoft.com", selector2: "..." }
*
* Returns a list of finding objects like
* { selector: "selector2", issue: "missing"|"wrong_type"|"target_mismatch", found: ..., expected: ... }
* for each selector that fails to resolve as a CNAME to its expected target.
* An empty array means healthy.
*/
export function checkDkimSelectors(selectorRecords, expectedTargets) {
const findings = [];
for (const [selector, expected] of Object.entries(expectedTargets)) {
const record = selectorRecords[selector] || { type: null, target: null };
const { type, target } = record;
if (type === null || type === undefined) {
findings.push({ selector, issue: "missing", found: null, expected });
continue;
}
if (type !== "CNAME") {
findings.push({ selector, issue: "wrong_type", found: type, expected });
continue;
}
if (target !== expected) {
findings.push({ selector, issue: "target_mismatch", found: target, expected });
}
}
return findings;
}
/** Query CNAME (and TXT, to detect a wrong-type conflict) for both selectors. */
async function querySelectorRecords(domain) {
const dns = await import("node:dns/promises");
const records = {};
for (const selector of ["selector1", "selector2"]) {
const name = `${selector}._domainkey.${domain}`;
try {
const answer = await dns.resolveCname(name);
records[selector] = { type: "CNAME", target: answer[0].replace(/\.$/, "") };
continue;
} catch {
// fall through to check TXT
}
try {
await dns.resolveTxt(name);
records[selector] = { type: "TXT", target: null };
} catch {
records[selector] = { type: null, target: null };
}
}
return records;
}
/** Find an existing record of recordType at the selector name via the Cloudflare API. */
async function findConflictingRecordId(domain, selector, recordType) {
const name = `${selector}._domainkey.${domain}`;
const url = `${CF_API}/zones/${CLOUDFLARE_ZONE_ID}/dns_records?type=${recordType}&name=${name}`;
const res = await fetch(url, { headers: { Authorization: `Bearer ${CLOUDFLARE_API_TOKEN}` } });
if (!res.ok) throw new Error(`Cloudflare list failed: ${res.status}`);
const body = await res.json();
const result = body.result || [];
return result.length ? result[0].id : null;
}
/** Delete a conflicting TXT record, if any, then create the correct CNAME. */
async function publishSelectorCname(domain, selector, target, conflictingTxtId) {
const name = `${selector}._domainkey.${domain}`;
const headers = {
Authorization: `Bearer ${CLOUDFLARE_API_TOKEN}`,
"Content-Type": "application/json",
};
const base = `${CF_API}/zones/${CLOUDFLARE_ZONE_ID}/dns_records`;
if (DRY_RUN) {
if (conflictingTxtId) console.log(`[dry run] would delete TXT record ${conflictingTxtId} at ${name}`);
console.log(`[dry run] would create CNAME ${name} -> ${target}`);
return;
}
if (conflictingTxtId) {
const del = await fetch(`${base}/${conflictingTxtId}`, { method: "DELETE", headers });
if (!del.ok) throw new Error(`Cloudflare delete failed: ${del.status}`);
}
const create = await fetch(base, {
method: "POST",
headers,
body: JSON.stringify({ type: "CNAME", name, content: target, proxied: false }),
});
if (!create.ok) throw new Error(`Cloudflare create failed: ${create.status}`);
console.log(`Published CNAME ${name} -> ${target}`);
}
async function run() {
const expectedTargets = {
selector1: process.env.DKIM_SELECTOR1_TARGET || "",
selector2: process.env.DKIM_SELECTOR2_TARGET || "",
};
if (!expectedTargets.selector1 || !expectedTargets.selector2) {
console.warn("Set DKIM_SELECTOR1_TARGET and DKIM_SELECTOR2_TARGET from Get-DkimSigningConfig first.");
return;
}
const records = await querySelectorRecords(DNS_DOMAIN);
const findings = checkDkimSelectors(records, expectedTargets);
if (findings.length === 0) {
console.log(`Both DKIM selectors are healthy for ${DNS_DOMAIN}.`);
return;
}
for (const finding of findings) {
console.warn(`selector=${finding.selector} issue=${finding.issue} found=${finding.found} expected=${finding.expected}`);
}
if (!(CLOUDFLARE_API_TOKEN && CLOUDFLARE_ZONE_ID)) {
console.warn("Issues found but no Cloudflare credentials set. Skipping repair.");
return;
}
for (const finding of findings) {
let conflictingTxtId = null;
if (finding.issue === "wrong_type") {
conflictingTxtId = await findConflictingRecordId(DNS_DOMAIN, finding.selector, "TXT");
}
await publishSelectorCname(DNS_DOMAIN, finding.selector, finding.expected, conflictingTxtId);
}
}
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 check function is the part worth testing, because it decides which selectors the script thinks are broken. It takes plain dictionaries, no network, no DNS lookups, so the tests run in milliseconds.
from dkim_selector_check import check_dkim_selectors
EXPECTED = {
"selector1": "selector1-yourdomain-com._domainkey.yourdomain.onmicrosoft.com",
"selector2": "selector2-yourdomain-com._domainkey.yourdomain.onmicrosoft.com",
}
def test_healthy_when_both_selectors_match():
records = {
"selector1": {"type": "CNAME", "target": EXPECTED["selector1"]},
"selector2": {"type": "CNAME", "target": EXPECTED["selector2"]},
}
assert check_dkim_selectors(records, EXPECTED) == []
def test_missing_selector_is_flagged():
records = {
"selector1": {"type": "CNAME", "target": EXPECTED["selector1"]},
"selector2": {"type": None, "target": None},
}
findings = check_dkim_selectors(records, EXPECTED)
assert len(findings) == 1
assert findings[0]["selector"] == "selector2"
assert findings[0]["issue"] == "missing"
def test_txt_instead_of_cname_is_wrong_type():
records = {
"selector1": {"type": "CNAME", "target": EXPECTED["selector1"]},
"selector2": {"type": "TXT", "target": None},
}
findings = check_dkim_selectors(records, EXPECTED)
assert findings[0]["issue"] == "wrong_type"
assert findings[0]["found"] == "TXT"
def test_wrong_target_is_flagged_as_mismatch():
records = {
"selector1": {"type": "CNAME", "target": EXPECTED["selector1"]},
"selector2": {"type": "CNAME", "target": "someone-elses-tenant._domainkey.example.onmicrosoft.com"},
}
findings = check_dkim_selectors(records, EXPECTED)
assert findings[0]["issue"] == "target_mismatch"
import { test } from "node:test";
import assert from "node:assert/strict";
import { checkDkimSelectors } from "./dkim-selector-check.js";
const EXPECTED = {
selector1: "selector1-yourdomain-com._domainkey.yourdomain.onmicrosoft.com",
selector2: "selector2-yourdomain-com._domainkey.yourdomain.onmicrosoft.com",
};
test("healthy when both selectors match", () => {
const records = {
selector1: { type: "CNAME", target: EXPECTED.selector1 },
selector2: { type: "CNAME", target: EXPECTED.selector2 },
};
assert.deepEqual(checkDkimSelectors(records, EXPECTED), []);
});
test("missing selector is flagged", () => {
const records = {
selector1: { type: "CNAME", target: EXPECTED.selector1 },
selector2: { type: null, target: null },
};
const findings = checkDkimSelectors(records, EXPECTED);
assert.equal(findings.length, 1);
assert.equal(findings[0].selector, "selector2");
assert.equal(findings[0].issue, "missing");
});
test("txt instead of cname is wrong type", () => {
const records = {
selector1: { type: "CNAME", target: EXPECTED.selector1 },
selector2: { type: "TXT", target: null },
};
const findings = checkDkimSelectors(records, EXPECTED);
assert.equal(findings[0].issue, "wrong_type");
assert.equal(findings[0].found, "TXT");
});
test("wrong target is flagged as mismatch", () => {
const records = {
selector1: { type: "CNAME", target: EXPECTED.selector1 },
selector2: { type: "CNAME", target: "someone-elses-tenant._domainkey.example.onmicrosoft.com" },
};
const findings = checkDkimSelectors(records, EXPECTED);
assert.equal(findings[0].issue, "target_mismatch");
});
Case studies
The domain that only got half its selectors
A company moved its domain to a new Microsoft 365 tenant and copied the DKIM setup instructions from an internal wiki page written a year earlier. Only selector1 got published, since the wiki page predated the second selector being part of the standard instructions. Everything worked fine for months.
Then Microsoft rotated signing to selector2 during routine key rotation, and DKIM started failing silently. Checking both selectors with dig found the second one returning nothing at all. Publishing the missing CNAME from the current Get-DkimSigningConfig output fixed it within the hour.
The TXT record that looked like it should work
An admin followed an older third-party guide that showed DKIM as a TXT record, and pasted the CNAME target string as a TXT value at selector2._domainkey because that was the only record type the guide mentioned. Defender showed "Signing not enabled" with no clear reason why.
Querying TXT at that name confirmed the record existed but as the wrong type. Deleting the TXT record and creating a CNAME with the exact same target string resolved the error immediately, and DKIM signing enabled cleanly on the next try.
Both selector1._domainkey and selector2._domainkey resolve as CNAME records pointing at the exact hostnames Microsoft issued for your tenant, at all times, even though only one selector is actively signing mail at any given moment. Get-DkimSigningConfig shows signing enabled, and a test message to an outside mailbox comes back with a DKIM pass on whichever selector is currently active.
FAQ
Why does Microsoft 365 want CNAME records for DKIM instead of a TXT record with the key?
Microsoft signs your mail with keys it controls and rotates on its own schedule. Instead of handing you a raw public key to paste into a TXT record, it asks you to publish two CNAME records that point at a hostname on its infrastructure. That way Microsoft can rotate the actual key at any time without you ever needing to touch DNS again.
What happens if only one of the two DKIM selectors is published?
Microsoft rotates signing between the two selectors on a schedule. If it rotates to the selector whose CNAME is missing or wrong, DKIM signatures stop validating for your domain. With DMARC set to quarantine or reject, that can cause real mail to be quarantined or rejected by receiving servers.
How do I fix a DKIM selector that was published as a TXT record instead of a CNAME?
A single DNS name cannot hold both a CNAME and a TXT record. Delete the TXT record at that selector name first, then create the CNAME pointing at the exact target Microsoft issued for your tenant, which you can get from Get-DkimSigningConfig or the Defender portal.
Related field notes
Citations
On the problem:
- Microsoft Learn: How to use DKIM for email in your custom domain. learn.microsoft.com/en-us/defender-office-365/email-authentication-dkim-configure
- RFC 6376: DomainKeys Identified Mail (DKIM) Signatures. rfc-editor.org/rfc/rfc6376
- DKIM Selectors, Rotation, and Key Length: Practical Guide. smartsmssolutions.com/resources/blog/business/dkim-selector-rotate-best-practices
On the solution:
- Microsoft Learn: How to use DKIM for email in your custom domain. learn.microsoft.com/en-us/defender-office-365/email-authentication-dkim-configure
- Cloudflare DNS docs: Create DNS records. developers.cloudflare.com/dns/manage-dns-records/how-to/create-dns-records
- Cloudflare API reference: DNS Records, create. developers.cloudflare.com/api/resources/dns/subresources/records/methods/create
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 mail?
If this saved you a bounced campaign or a confusing rotation error, 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