All five of my domains send email. None of them run a mail server. The password manager sends invites, the newsletter has a welcome mail, monitoring alerts go out through an ESP. Until this week, for two of those domains, a stranger could have sent mail as me and most receiving servers would have shrugged and delivered it.

Fixing that took one afternoon across five zones, all of it through the Cloudflare API. This is the pass I ran: DMARC at reject, MTA-STS, TLS reporting, and CAA. Plus the two traps I hit that the tutorials skip.

Order of operations

  1. Inventory who legitimately sends as each domain. Usually it is one ESP.
  2. Confirm aligned DKIM on a real sent message.
  3. Publish DMARC at reject, or quarantine if step 1 surprised you.
  4. If reports go to an address on another domain, publish the authorization record there.
  5. Deploy the MTA-STS endpoint and its TXT record, in testing mode if your inbound path is exotic.
  6. Add TLS-RPT.
  7. Verify MX match and the policy fetch, then switch to enforce.
  8. Finish with CAA.

The sixty second version of SPF, DKIM, and DMARC

SPF lists which servers may send mail using your domain in the return path. DKIM is a cryptographic signature your sender attaches that names the signing domain and proves the message was not altered. DMARC ties them together: it tells receivers what to do when a message claims to be from your domain and neither check passes in an aligned way.

The trap is that the policy most guides leave you with is p=none. That is monitoring mode. Reports get generated, nothing gets blocked. A spoofed invoice from billing@yourdomain.com gets no pushback from your policy. Receivers may still junk it on reputation or content, but that is their call, not yours. If your record says none, you have visibility, and you are formally asking receivers to do nothing about failures.

Going straight to reject

Conventional wisdom says step through quarantine and watch reports for a few weeks. That advice is written for companies with a dozen mail sources nobody fully remembers. A homelab domain usually has exactly one legitimate sender, your ESP, and you know precisely what it is. If your aggregate reports have been clean and your ESP signs DKIM as your domain, go to reject.

_dmarc.benchnotes.net  TXT  "v=DMARC1; p=reject; sp=reject; pct=100; rua=mailto:reports@yourdomain.com"

Two details that matter. The p policy already applies to subdomains unless something overrides it, so sp=reject is explicit hardening rather than a requirement. Pinning it means a future edit to p, or a stray subdomain record, cannot quietly loosen the coverage. And the prerequisite is aligned DKIM: your ESP must sign with d=yourdomain.com, which is what those CNAME records from ESP setup accomplish. Open a message you sent, view the headers, and confirm dkim=pass with your own domain before flipping the switch.

One honest caveat

Reject means misconfigured legitimate mail bounces instead of landing in spam. On a low volume domain that is a feature, because you will notice a bounce. On a domain where other people send mail you have not inventoried, walk through quarantine first.

The report trap nobody mentions

DMARC aggregate reports go to whatever address you put in rua. Here is the part I had wrong for months: if that address is on a different domain than the one being reported on, receivers check for an authorization record before sending, and silently skip the report when it is missing. Point benchnotes.net reports at a protonmail address and Google simply never sends them. No error, no bounce. You just receive fewer reports and assume the internet is quiet.

The fix is to consolidate reports at an address on a domain you control and publish one authorization TXT per reporting domain, on the domain that hosts the inbox:

benchnotes.net._report._dmarc.yourdomain.com  TXT  "v=DMARC1"

One inbox, every domain, and nothing skipped for a missing authorization record. Receivers still decide when and whether to send reports at all; the record just removes your side of the failure. TLS reporting, below, does not need any of this. The verification rule is specific to DMARC.

MTA-STS in twenty lines of Worker

DMARC protects other people from mail pretending to be you. MTA-STS protects mail coming to you: it tells sending servers they must use verified TLS to your MX hosts, which closes off downgrade attacks on inbound delivery. MTA-STS itself needs one HTTPS endpoint and one TXT record. TLS-RPT, the reporting side, adds a second TXT record, and both appear below.

The endpoint serves a plain text policy at https://mta-sts.yourdomain.com/.well-known/mta-sts.txt. A Worker is the entire implementation:

addEventListener('fetch', e => {
  const url = new URL(e.request.url);
  if (url.pathname === '/.well-known/mta-sts.txt') {
    e.respondWith(new Response(
      "version: STSv1\r\nmode: enforce\r\nmx: route1.mx.cloudflare.net\r\nmx: route2.mx.cloudflare.net\r\nmx: route3.mx.cloudflare.net\r\nmax_age: 86400\r\n",
      { headers: { 'content-type': 'text/plain' } }
    ));
  } else {
    e.respondWith(new Response('Not found', { status: 404 }));
  }
});

The spec wants CRLF line endings in the policy body, hence the \r\n. Plenty of parsers tolerate bare newlines. Be standards-clean anyway, it costs nothing. The example is the classic service worker format; the module syntax version is the same logic inside a fetch handler, so use whichever your editor hands you.

Route mta-sts.yourdomain.com/* to it. A proxied AAAA record pointing at 100:: gives the hostname something to resolve to, though Cloudflare now prefers Worker Custom Domains for this, which skip the placeholder record entirely. Then publish:

_mta-sts.yourdomain.com    TXT  "v=STSv1; id=20260707120000"
_smtp._tls.yourdomain.com  TXT  "v=TLSRPTv1; rua=mailto:reports@yourdomain.com"

The id is a cache key, and it is the gotcha that will bite you in two years. If you ever change MX providers, updating the policy file is not enough, because senders cache the old one. You must also bump the id so they refetch. Write that down next to wherever you document your MX records.

Before enforce, verify. Three commands, from outside your network:

dig MX yourdomain.com +short
dig TXT _mta-sts.yourdomain.com +short
curl -i https://mta-sts.yourdomain.com/.well-known/mta-sts.txt

Good looks like this: the MX hosts returned by dig match the mx lines in your policy exactly, the TXT shows your current id, and the curl returns 200 over a valid certificate with the policy body. Senders validate that certificate, not just the content, so a self-signed or mismatched cert fails silently from your side.

I went straight to mode: enforce because my MX is Cloudflare Email Routing with valid certificates. The max_age of one day is a rollout value that keeps the blast radius small while you confirm nothing broke. The spec expects weeks or more at steady state, so once a clean week of TLS reports comes in, raise it to one or two weeks (604800 or 1209600) and bump the id. If your inbound mail path is more exotic, start with mode: testing and read the TLS reports for a week.

CAA while you are in there

CAA records tell certificate authorities who may issue certificates for your domain. Without them, any CA on earth will issue to whoever passes validation. With them, issuance outside your list is refused, and supporting CAs can report refused attempts to your iodef address.

yourdomain.com  CAA  0 issue "letsencrypt.org"
yourdomain.com  CAA  0 issue "pki.goog"
yourdomain.com  CAA  0 issue "ssl.com"
yourdomain.com  CAA  0 issuewild "letsencrypt.org"
yourdomain.com  CAA  0 issuewild "pki.goog"
yourdomain.com  CAA  0 issuewild "ssl.com"
yourdomain.com  CAA  0 iodef "mailto:you@yourdomain.com"

Those three CAs are the ones Cloudflare Universal SSL uses, so proxied zones keep renewing. The issuewild entries are optional in the strict sense: when no issuewild exists, plain issue records authorize wildcard certificates too. Publishing them anyway makes the wildcard policy explicit, which matters the day someone adds a single issuewild record and silently changes what the issue lines cover. Cloudflare will also insert records for its CAs at the edge if it needs one you missed, which is a decent safety net but not a reason to do it wrong.

What still bit me

Two things, both worth a line in your notes file.

First, authentication is not reputation. Hours after all of this went live, I test subscribed to my own newsletter with an msn.com address. The ESP sent it, Microsoft accepted it, DMARC passed under a reject policy, and the message went straight to Junk anyway. A new sender on shared ESP IPs starts in the penalty box no matter how clean the DNS is. The records stop spoofing. Inbox placement is a separate and slower fight.

Second, if any machine in your house runs a VPN client, do not trust curl ifconfig.me from it when grabbing your home IP for an allowlist. My desktop quietly routes through a WireGuard adapter with a metric zero default route. I put a VPN exit node into an access policy before catching it, which meant every stranger on that exit node shared my bypass. Check the IP against your router, or a device you know sits on the raw WAN.

Worth writing down while it is fresh: where the report inbox lives, the MTA-STS policy URL and its current id, your MX hosts, the CAA issuer list, and your ESP DKIM selectors. Every future change touches at least one of them.


The whole pass was one afternoon, five domains, nothing but DNS records and one tiny Worker. Success looks like boring reports: everything passing, nothing rejected that should not be. Set it up, watch it for a week, then it goes quiet until the day you change MX hosts or ESPs. That day, the id bump and the notes you wrote are the whole job.