Chapter 4 of 8. Three DNS records decide whether your confirmation email lands in an inbox or a spam folder, and the cPanel tutorials all assume cPanel owns your DNS. Prerequisite: Chapter 1, and you should already have a domain on a cPanel host with mail enabled and know which nameservers your zone is delegated to.
I budgeted an afternoon for this. Publish SPF, publish DKIM, publish DMARC, test, move on. Then I ran the inventory before writing anything, and most of it was already correct. The devlog entry from that day says it plainly: "So the gap was just DMARC tightening, not the full SPF/DKIM/DMARC publish I'd budgeted for."
That is the good case. It is the case where cPanel's own nameservers are authoritative for the zone, so the records cPanel writes are the records the internet reads. Put Cloudflare, Route 53, or your registrar's DNS in front of that host and every assumption in the paragraph above breaks, quietly, in a way that still passes a casual test. This chapter covers both, including what happens when cPanel and an external provider each publish a v=spf1 record for the same domain.
What cPanel publishes for you, and what each record means
Here is what a dig at the zone's authoritative nameserver returns for this site. Three records, all live, all real.
$ dig +short captainrandom.co.uk TXT
"v=spf1 +a +mx +ip4:51.195.234.93 ~all"
$ dig +short default._domainkey.captainrandom.co.uk TXT
"v=DKIM1; k=rsa; p=MIIBIjANBgkqhki..." # full RSA public key, published by cPanel AutoDKIM
$ dig +short _dmarc.captainrandom.co.uk TXT
"v=DMARC1; p=quarantine; rua=mailto:hello@captainrandom.co.uk; fo=1;"
The SPF record reads as a list of authorised senders. +a authorises whatever IP the domain's own A record resolves to. +mx covers the IPs behind its MX hosts. +ip4:51.195.234.93 names one literal address. That address is the JustHostMe London box. It is both the A and the MX target here. Everything else gets softfailed by ~all.
Softfail is the correct terminator for a same-box setup with no external relay. Only this host is ever supposed to send as this domain, so a hardfail looks tempting. But RFC 7208 section 8.4 hands the decision to the receiver: "Disposition of SPF fail messages is a matter of local policy." A hardfail is an invitation to reject outright, and plenty of receivers take it. On a newsletter, one over-eager rejection costs you a subscriber.
Two rules from RFC 7208 shape everything that follows. First, section 3.1: "SPF records MUST be published as a DNS TXT (type 16) Resource Record (RR) only." The dedicated SPF record type from the old RFC 4408 is deprecated, so if your DNS provider offers a record type named SPF, ignore it and use TXT. Second, section 4.6.4: the DNS-querying terms (include, a, mx, ptr, exists, redirect) are capped at 10 per evaluation, and exceeding the cap returns permerror. The record above spends two of its ten on +a and +mx. Every ESP you bolt on with an include: spends at least one more.
DKIM is published at a selector. RFC 6376 defines the lookup. The verifier takes the selector and the domain from the signature header and fetches the public key from selector._domainkey.domain. cPanel's AutoDKIM uses the selector default. That is what puts the key above at default._domainkey.captainrandom.co.uk. It was already on from initial setup. I did not publish it, and on a cPanel-authoritative zone you probably will not have to either.
DMARC is the one that was missing. It existed as v=DMARC1; p=none;: monitor-only, no reporting address. A lot of hosts leave you sitting there and call it configured.
When Cloudflare or any external DNS sits in front of cPanel
Authority follows the NS delegation. If your domain's NS records point at Cloudflare, then Cloudflare's zone is the only zone the internet ever reads. cPanel still has a zone file. Its Zone Editor still works. AutoDKIM writes its key into that local zone, where no resolver on earth will look for it.
Three consequences, in the order they bite.
Your DKIM key is missing in production
cPanel is signing outbound mail with a private key whose matching public record exists only in a zone nobody queries. Per RFC 6376 the verifier fetches the key from DNS. No record, no verification. With no fetchable key, DKIM cannot produce a passing aligned identifier for DMARC, so your entire DMARC result rests on SPF alone. The cPanel UI says DKIM is enabled. That is why this survives testing by eyeball. Fix: copy the key value cPanel generated and publish it at default._domainkey.yourdomain in the external provider, as a TXT record, then confirm with dig that the world can see it.
A second v=spf1 record now exists
cPanel wrote one into its own zone. The ESP's setup wizard had you write another at the external provider. Two v=spf1 records at the same name do not merge, and the whole next section is that failure.
Your +a mechanism may be pointing at the wrong machine
SPF's a mechanism resolves at check time against whatever A record the world currently sees. Put a reverse proxy in front of your apex and the A record stops resolving to your cPanel mail server. +a now authorises the proxy's IPs. The box that sends your confirmation email is no longer on the list. The record on this site survives that scenario by accident of design, because it also carries +ip4:51.195.234.93 explicitly.
For the record: captainrandom.co.uk does not run Cloudflare. The site goes direct to JustHostMe LiteSpeed, and the absence of a Cloudflare WAF is an accepted, documented gap in this repo's security notes. The DNS in this chapter is cPanel-authoritative and the DMARC edit later on goes through cPanel's own API. I am telling you the external-DNS failure modes because they follow directly from the RFCs and from how delegation works, and because it is the case most indie developers are in.
The duplicate v=spf1 failure: two records do not merge
Here is how a cPanel newsletter loses SPF. cPanel publishes v=spf1 +a +mx ... ~all into its zone. Then your ESP's setup wizard has you publish an SPF record at the external provider too. Now the authoritative zone answers a TXT query with two records that both start v=spf1.
The belief that they merge comes straight from the tooling. Every ESP onboarding wizard tells you to "add an SPF record to your DNS", which implies that addition is a safe operation and that merging is an optional nicety for tidy people. They do not merge. RFC 7208 section 3.2 is a MUST: "A domain name MUST NOT have multiple records that would cause an authorization check to select more than one record." Section 4.5 says what happens when you break it: "If the resultant record set includes more than one record, check_host() produces the permerror result."
permerror is a permanent error on the whole SPF evaluation. Both records are discarded, including the correct one. A softfail at least leaves a receiver something to weigh. A permerror leaves it nothing to weigh, so DMARC has no SPF result to align against, and your confirmation email survives only if DKIM alone passes and aligns.
The fix is one TXT record at the apex of the authoritative zone, carrying every mechanism you need and terminated once.
# WRONG: two records, permerror, whole check discarded
example.co.uk. IN TXT "v=spf1 +a +mx ~all"
example.co.uk. IN TXT "v=spf1 include:_spf.someesp.com ~all"
# RIGHT: merged into one, mechanisms concatenated, one terminator
example.co.uk. IN TXT "v=spf1 +a +mx ip4:203.0.113.10 include:_spf.someesp.com ~all"
Count the DNS-querying terms as you merge. a is one, mx is one, each include: is at least one and often several once the ESP's own record expands. Cross ten and you are back at permerror by a different route, per RFC 7208 section 4.6.4.
dig tells you what the internet reads
The control panel shows you what it thinks it published. dig shows you what the internet can read. Only the second one matters, and under external DNS they routinely disagree.
- Find who owns the zone
dig +short NS yourdomain.co.uk. Whatever comes back owns your zone. If it is your registrar or Cloudflare, cPanel's Zone Editor is decorative and every record below must exist at that provider. - Count your SPF records
dig +short yourdomain.co.uk TXT | grep -c 'v=spf1'. The only acceptable answers are0(you have no SPF) or1. Any number above1is thepermerrorfrom RFC 7208 section 4.5, and it is your top-priority fix. - Prove the DKIM key is visible
dig +short default._domainkey.yourdomain.co.uk TXT. Empty output means cPanel is signing with a key nobody can fetch. Substitute your selector if the host uses something other thandefault. - Read your real DMARC policy
dig +short _dmarc.yourdomain.co.uk TXT. A missing record, orp=nonewith norua=, means you are publishing nothing and learning nothing. - Query the authoritative nameserver directly
dig @<authoritative-ns> yourdomain.co.uk TXT. That bypasses your resolver's cache. Immediately after an edit, it is the only reading you can trust. On this site the verification command wasdig @dns1.greatbritishwebhosting.net _dmarc.captainrandom.co.uk TXT, and it returned the new value straight away.
Publishing the DMARC record on cPanel, including the API that no longer exists
DMARC is a TXT record at _dmarc.<domain>, per RFC 7489. The p= tag takes none, quarantine, or reject, and rua= takes a mailto: URI for aggregate reports. That is the whole surface you need to start.
Editing it on this cPanel build had one genuine surprise in it. Every tutorial reaches for DNS::edit_zone_record. On this server build the API answers "Could not find edit_zone_record in module DNS". Its replacement, DNS::mass_edit_zone, takes the zone, the current SOA serial as an optimistic lock, and a JSON edit-N payload per record. Read the existing record out of parse_zone before you touch anything. On this zone it sat at line_index 39 with TTL 14400.
uapi DNS mass_edit_zone \
zone=captainrandom.co.uk \
serial=2026050901 \
edit-0='{"line_index":39,"dname":"_dmarc","record_type":"TXT","ttl":14400,
"data":["v=DMARC1; p=quarantine; rua=mailto:hello@captainrandom.co.uk; fo=1;"]}'
The SOA serial went from 2026050901 to 2026060900. A silent no-op would have left it alone. Then the authoritative dig above confirmed the new value.
Choosing p=quarantine over p=reject is a deliberate trade. RFC 7489 defines the pct= tag as the percentage of mail the requested policy is applied to. A staged rollout runs on that tag. The none to quarantine to reject progression is industry practice rather than something the RFC prescribes, and the practice is to sit at each step long enough to read the aggregate reports before tightening. Quarantine tells receivers to treat unauthenticated mail claiming to be you as suspicious; reject tells them to bin it outright. On a domain whose only sender is one PHP script, reject is defensible eventually, but let real users through over false positives while the reports are still young.
Alignment: why your From header and your envelope sender must be the same domain
You can pass SPF and still fail DMARC. RFC 7489 requires identifier alignment: the domain in the From: header the human sees must align with the domain SPF authenticated (the envelope sender) or the domain in the DKIM d= tag. An SPF pass on some other domain does not satisfy DMARC; the check is on the domain the human sees in From:.
The PHP mail() manual documents the fifth argument as flags handed to the program configured in sendmail_path. -f sender@example.com sets the envelope sender, and the envelope sender is the identity SPF checks. So the last line of Email::sendConfirmation() is where alignment stops being a DNS problem and becomes a code problem:
$headers = sprintf("From: %s <%s>\r\n", self::encodeName($name), $from);
$headers .= sprintf("Reply-To: %s\r\n", $from);
$headers .= sprintf("Return-Path: %s\r\n", $from);
// ...
return mail($to, $subject, $body, $headers, '-f ' . $from);
One value, $from, read from the FROM_EMAIL environment variable, drives the header From, the Reply-To, the Return-Path, and the envelope sender handed to sendmail. Header and envelope cannot drift apart, so SPF authenticates the same domain DMARC is checking alignment against.
The order matters, so take it in sequence. SPF and DKIM were already correct and DMARC was still sitting at p=none with no rua= when I sent the first test. That test was a plain mail() send from the cPanel host to a fresh mail-tester address, the same API the backend now uses. It scored 9.2/10. The missing 0.8 was SpamAssassin scoring the content of the short test message, with no authentication or policy hit anywhere in the report. The one soft note in the auth section was an advisory pointing at the p=none.
I then tightened DMARC to p=quarantine and scored a second send. It came back 9.2 again. The authentication surface had been correct before the tightening ever happened; tightening the policy only changed what receivers are asked to do when something fakes the domain. Your own mail was already passing. The policy exists for the mail that pretends to be yours.
The transferable rule
Inventory the zone before you budget the work, and inventory it with dig against the authoritative nameserver rather than with the panel that claims to own it. Authority follows the NS delegation. If an external provider is authoritative, cPanel's records are a local fiction and your DKIM key never reached the world.
Merge to a single SPF record at the apex. Prove the selector's key is visible from outside the box, and publish a DMARC policy that asks receivers to do something. Then verify against the wire, because a mail server does not read your control panel.
Authenticate your own domain's mail, end to end
Run the full authentication build on a domain you control that sends any automated mail (app notifications, receipts, alerts, a newsletter), including whatever ESPs and third-party senders are in the picture: one merged SPF record at the authoritative zone, a DKIM key the world can fetch, and a DMARC policy with reporting turned on.
Expected behaviour
- dig +short NS names your authoritative provider, and every record below exists there rather than in a panel nobody queries
- Exactly one v=spf1 TXT record at the apex, carrying every legitimate sender, with the DNS-querying terms counted and kept under ten
- The DKIM selector record returns a key when queried from outside your own host
- A DMARC record with a p= policy stronger than none and a rua= address that receives aggregate reports
- A scored test send with no authentication failures anywhere in the report
PROVE IT Capture the dig queries and the mail-tester report side by side, then show one aggregate DMARC report arriving at your rua= address.
Your ESP wizard had you add a second v=spf1 TXT record next to the one cPanel published. What do receivers now compute?
Which UAPI DNS function replaced edit_zone_record on this cPanel build?
How can a message pass SPF and still fail DMARC?
Show answer
DMARC requires identifier alignment: the domain in the From header the human sees must align with the domain SPF authenticated, meaning the envelope sender, or with the DKIM d= domain. An SPF pass on some other domain does nothing for DMARC, which is why Email.php drives the header From, the Return-Path and the sendmail -f envelope sender from the single FROM_EMAIL value so header and envelope cannot drift apart.
↺ re-read: “Alignment: why your From header and your envelope sender must be the same domain”