Skip to content

SPF, DKIM and DMARC, explained by the records you actually publish

The three records, what each one proves, and the exact values for your transport. Generated from the same code the setup screen uses, so the guide cannot drift from the product.

DeliverabilityBeginner13 min readUpdated

01What each record actually proves

These are three different claims and they are constantly conflated, including by tools that should know better. Getting the distinction straight takes two minutes and saves an afternoon.

TL;DR

SPF and DKIM prove something about the delivery. DMARC is the only one that connects that proof to the name in the From line — which is the only part a recipient ever sees, the only one that lets you ask receivers to reject forgeries, and the only one that sends you reports.

RecordWhat it provesChecked againstUnder forwarding
SPFA list of servers allowed to send for this domain.The envelope sender — the bounce address — not the From header a reader sees.Breaks. The forwarder is not on your list.
DKIMA cryptographic signature over the message itself, verified against a public key the receiver fetches from your DNS.The body hash and a fixed list of headers.Survives. The signature travels with the message.
DMARCA policy saying what to do when neither of the above aligns with the From domain.The address a human reads. It is the only one of the three that is.Passes on whichever of the two still aligns.

This is why one status is not enough. A verify returns three separate signals alongside the roll-up — dkim_ready, spf_ready and dmarc_policy — because collapsing them hides the two states that matter.

DKIM without SPF
  • A domain that delivers, and then fails alignment.
SPF without DMARC
  • A domain nobody is watching, including you.

02The records for your domain

Enter your domain and pick your transport. These rows come from the transport adapter’s own dnsRecords() function — the same code that produces your setup screen and then verifies what you published.

DNS RECORDS

Generated by each adapter's own dnsRecords() in @mailysend/providers — the same function that produces the rows on your setup screen.

TXTyourdomain.compublished for you
v=spf1 include:_spf.mx.cloudflare.net ~all

Authorises Cloudflare to send as this domain (SPF). Cloudflare adds this itself.

TXTcf-bounce._domainkey.yourdomain.compublished for you
v=DKIM1

DKIM key, minted and published by Cloudflare when you onboard the domain.

MXcf-bounce.yourdomain.compriority 10published for you
mx.cloudflare.net

Bounce collection, published by Cloudflare. Without it, DSNs are lost.

TXT_dmarc.yourdomain.compublished for you
v=DMARC1; p=none; rua=mailto:[email protected]

Turns on DMARC reporting. Start at p=none, tighten once reports are clean.

Rows marked published for you are written by the provider when you onboard the domain — there is nothing to copy, and verification confirms they exist rather than blocking on you. The DKIM value shown is a placeholder wherever the provider mints its own key under its own selector: printing a key that will not be the real one is worse than printing none.

TransportSPFDKIM rowReturn-path MX
Cloudflare Email Serviceinclude:_spf.mx.cloudflare.net at the apexcf-bounce._domainkey, holding Cloudflare’s own keycf-bounce.yourdomain.commx.cloudflare.net
SESinclude:amazonses.com at the apex, plus a second SPF record on the return-path subdomainms1._domainkey, holding the key MailySend generated and handed to SES as a BYODKIM signing attributeOn the return-path subdomain — a custom MAIL FROM domain, which is the thing that makes SPF align
ResendOn send.yourdomain.comNone issued here: the key is theirs and is fetched live from their APIOn send.yourdomain.com
SMTP relayv=spf1 a mx include:<your relay host> ~allms1._domainkey, holding our key — a raw relay signs nothing, so this row is not optionalThe return path is used as the envelope sender

All four also get a DMARC row, and it is the same row in every case: v=DMARC1; p=none; rua=mailto:[email protected].

A domain bound to a transport gets that transport’s rows and nothing else. A domain that names no transport shows the union across every provider the router could yield, because a workspace that fails over mid-incident needs the second transport’s records already in place. Where that union would produce two apex v=spf1 records, the includes are merged into one legal record — two SPF records at one name is a permanent error, not belt-and-braces.

03Alignment is the part people miss

A message can pass SPF, pass DKIM, and still fail DMARC. This surprises people every time, and it is not a bug in anything.

TL;DR

DMARC does not ask “did SPF pass?”. It asks “did SPF pass for a domain that matches the From header?” — and a check that passed for somebody else’s domain proved nothing about yours.

FROM
what the reader sees — the only field DMARC is about
RETURN-PATH
what SPF checked: the vendor’s domain, not yours
DKIM d=
yourdomain.com
aligned — DMARC passes on this one alone
Relaxed alignment — the default for both checks
  • The two domains have to share an organisational domain rather than match exactly
  • That is the licence under which cf-bounce.yourdomain.com or send.yourdomain.com can carry the envelope sender and still align with a From at the apex
Strict alignment
  • Removes that licence: the domains must match exactly
  • A deliberate late step rather than a default

Forwarding is where the two checks diverge. A mailing list or a university address that forwards to Gmail re-sends your message from its own server, so the connecting IP is the forwarder’s and SPF fails — correctly, by its own rules, on a message that is genuinely yours. The DKIM signature travels inside the message and keeps verifying, and DMARC needs only one of the two to pass and align. SPF is the check that fails exactly when a message reaches a mailbox that is judging you, which is the whole argument for publishing DKIM when SPF already passes.

What breaks a surviving signature. The signature covers the body hash and a fixed list of headers, so a list server that appends a footer or rewrites Subject with a [list-name] tag invalidates it. Nothing on your side prevents that — which is why DKIM failures from one mailing list are a fact about the list, and a rise across many receivers is a fact about you.

This is why the record sets above put the bounce path on a subdomain of your domain rather than the transport’s, and why the SMTP adapter uses the return path as the envelope sender rather than the From address.

04Selectors, key sizes and who mints the key

A selector is just a label that says which key to fetch — ms1 means the receiver looks up ms1._domainkey.yourdomain.com. Having a selector name at all is what lets you rotate a key without a gap.

TL;DR

The keypair is generated per domain when the domain is added, the private half has no read path anywhere in the API — and on two of the four transports it is not the key doing the signing at all.

RSA-2048, not Ed25519. Ed25519 signatures are smaller and better, and a meaningful share of receivers still do not verify them. A signature nobody checks is not an improvement, so the default is the one that works everywhere: 2048-bit RSASSA-PKCS1-v1_5 over SHA-256.

DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=yourdomain.com;
 s=ms1; t=1757548800; bh=<base64 body hash>;
 h=from:to:subject:date:message-id:mime-version:content-type;
 b=<base64 signature>
TagWhat the signer emitsWhy
a=rsa-sha256The algorithm every receiver verifies.
c=relaxed/relaxedCollapses whitespace runs and drops trailing whitespace before hashing, so a message survives an MTA reformatting a header. Simple canonicalisation would fail on transformations nobody can see.
h=from, to, cc, subject, date, message-id, mime-version, content-type, content-transfer-encoding, reply-to, list-unsubscribe, list-unsubscribe-postFiltered down to the ones actually present. Listing a header that is not there is how a signature ends up covering a header an attacker can then add.
b=Folded at 72 columnsAn unfolded 400-character header is the thing some MTAs truncate.

The signature is prepended, so the Received lines each hop adds land above it and disturb nothing.

TransportWho actually signsUnder which selector
Cloudflare Email ServiceCloudflare, with its own key — the structured payload is what gets sent, not the MIME MailySend buildscf-bounce._domainkey
ResendResend, with its own keyIts own
SESSES, using the private key MailySend generated and handed over as a BYODKIM signing attributems1
SMTP relayMailySend. A raw relay signs nothing, which is why this row is not optional therems1

On SES the published record, the selector in the signature and the key doing the signing describe one key rather than three unrelated ones.

05Checking your work

Two lookups and you are done. Do them from a resolver rather than from your DNS provider’s own interface, which will happily show you a record it has not published yet.

dig +short TXT yourdomain.com
dig +short TXT ms1._domainkey.yourdomain.com
dig +short TXT _dmarc.yourdomain.com
TL;DR

Verification does the same lookup you just did: one DNS-over-HTTPS query per row against cloudflare-dns.com/dns-query, reported as one of four words.

WordWhat it meansWhat it is worth doing about it
verifiedIt resolved and it agreed.Nothing.
pendingNothing is published at that name yet.Publish it, or wait for it to propagate.
failedA record exists and disagrees.The actionable case — read the value that resolved.
errorThe lookup itself did not complete.No evidence about your zone at all. Without this fourth word a domain whose every row errored used to roll up to verified.
RowCompared howBecause
apex SPFOn its includesMerging our include into your existing record is the correct thing to do, and an exact match would punish it.
DKIM / DMARCOn its prefixThe value is one only the provider knows, so v=DKIM1 passes against a full key and tightening p=none to p=quarantine does not turn a verified row red.
everything elseExactlyAfter whitespace, a trailing dot and case are normalised away.

The domain rolls up conservatively. A domain is verified only when every row is; any failed row makes it failed; everything else is pending. Note what is not in that rule: rows the provider publishes for itself are not excluded, so a Cloudflare domain you have not yet onboarded sits at pending with nothing for you to copy. The response’s checked block — total, resolved, errored — is how you tell “still propagating” from “the resolver would not answer”.

The mistakeWhat it actually doesThe fix
Two SPF recordsA domain may publish exactly one. Two is a permanent error and receivers treat it as no SPF at all, so adding a second record for a new sender silently disables the first.Merge the includes into one record. The setup screen does this for you when two transports both want the apex, keeping the first record’s ~all or -all qualifier; what it cannot merge is a record some other tool added to your zone last year.
More than ten lookupsSPF evaluation is capped at ten mechanisms that require a lookup — include, a, mx, ptr, exists, redirect — counted recursively, so one include that itself includes three more spends four of your ten. Exceeding it is a permerror, which most receivers treat as no SPF.Count them by hand once the apex record passes three or four includes. Nothing in the verifier counts them for you: it checks that your includes are present, not that the tree beneath them is small enough.
A key your panel splitA TXT record is a sequence of strings of at most 255 bytes each, and a 2048-bit key does not fit in one. Well-behaved panels split the value into quoted strings a resolver concatenates back, and verification joins the chunks a DoH answer arrives in before comparing. Badly behaved panels truncate at 255 characters or insert whitespace at the split.Compare the p= value from dig, byte for byte, against the key you published. Because the row is prefix-matched on v=DKIM1, a truncated key can still verify here while the signature fails at every receiver.
p=quarantine with no ruaAn instruction to receivers with no feedback to you: you have asked mailbox providers to start quarantining mail that fails alignment, and arranged to hear nothing about which mail that is — including the invoicing system, the helpdesk and the CRM you forgot about.Publish p=none with a working rua first, read the reports until you recognise every source in them, and tighten after. That is why the default row for every transport is v=DMARC1; p=none; rua=mailto:[email protected], and the mailbox it names has to be one that can actually receive mail.
A registrar that appendsMany panels take the name you type and append the zone. Entering _dmarc.yourdomain.com then produces _dmarc.yourdomain.com.yourdomain.com, which resolves to nothing and looks correct in the interface.If dig disagrees with your DNS panel, believe dig.

The ten-lookup one fails in the worst possible way: it works until a vendor adds an include inside their own record, and your SPF stops passing with nothing having changed on your side.

What just happened

Three records are published and you can say what each one proves: SPF authorises a server, DKIM signs the message, DMARC checks that one of those two passed for the domain the reader sees. That last clause is alignment, and it is the part that turns two passing checks into a failing DMARC verdict.

Common questions

Read next

YOUR ACCOUNT, YOUR MAIL

Nothing to sign up for. Just deploy it.

Every guide on this site describes software you run yourself.