MX, SPF, DKIM, DMARC: the four records every domain that sends or receives mail needs
Four DNS records, ordered from "where does mail go" to "what should the recipient do if it doesn't look right." Each adds forge-resistance on top of the previous one. MX and SPF cover receiving and sending; DKIM is a sending-side control (a receive-only domain has no outbound mail to sign and publishes no DKIM key).
In one line:
example.com. MX + SPF + DKIM + DMARC
└── one domain ┘ └── the four records that deliver AND authenticate its mailWhat it holds#
| Record | Lives at | What it answers |
|---|---|---|
| MX | example.com (apex, or any name) | Where does inbound mail for @example.com go? |
| SPF (a TXT) | example.com (apex) | Which IPs are allowed to send as @example.com? |
| DKIM (a TXT) | <selector>._domainkey.example.com | The public key that verifies signatures on outbound mail. |
| DMARC (a TXT) | _dmarc.example.com | What should the recipient do when SPF or DKIM doesn't align with the visible From:? |
Each one without the others is brittle. All four together is the modern minimum.
When to use it#
- The domain receives mail: publish MX.
- The domain sends mail: publish SPF, DKIM, and DMARC so receivers can tell your mail from forgeries.
- The domain never touches mail: publish a null MX (
MX 0 ., RFC 7505), an SPF ofv=spf1 -all, and a DMARC ofp=reject. A registered domain with no mail records is still a forgery target; these three records tell receivers to reject anything claiming to be it.
When not to use it#
- No DKIM key on a receive-only domain. There is no outbound mail to sign, so there is no key to publish.
- Never a second SPF record at the same name. Two SPF TXT records is a
permerror(RFC 7208 §4.5); merge the mechanisms into one record. - Not
p=rejecton day one. Publishp=nonewithrua=, read the aggregate reports until alignment is confirmed, then promote. The promotion path is covered in the DMARC section below.
dig example#
Check three of the four records for any domain in one paste (DKIM needs the selector from a received message's DKIM-Signature: header, so it cannot be queried blind):
dig @1.1.1.1 cloudflare.com MX +short
dig @1.1.1.1 cloudflare.com TXT +short | grep v=spf1
dig @1.1.1.1 _dmarc.cloudflare.com TXT +short5 mxa-canary.global.inbound.cf-emailsecurity.net.
5 mxb-canary.global.inbound.cf-emailsecurity.net.
10 mxa.global.inbound.cf-emailsecurity.net.
10 mxb.global.inbound.cf-emailsecurity.net.
"v=spf1 ip4:199.15.212.0/22 ip4:173.245.48.0/20 include:_spf.google.com include:spf1.mcsv.net include:spf.mandrillapp.com include:mail.zendesk.com include:stspg-customer.com include:_spf.salesforce.com -all"
"v=DMARC1; p=reject; sp=reject; adkim=r; aspf=r; pct=100; rua=mailto:<token>@dmarc-reports.cloudflare.net,mailto:rua@cloudflare.com"An empty answer on the _dmarc. line means no DMARC record is published. MX record order varies between runs; the preference number, not the order, decides which server a sender tries first. Each record is unpacked in its own section below.
MX: where does mail go?#
A receiving mailserver looking to deliver you@example.com queries MX, gets a list of hostnames each carrying a preference value (the sender tries the lowest number first), then resolves each to an A/AAAA, then makes an SMTP connection.
example.com. MX 10 mx1.mailprovider.com.
example.com. MX 10 mx2.mailprovider.com.
example.com. MX 20 backup-mx.mailprovider.com.Mail for
example.comgoes tomx1.mailprovider.comfirst; the10is its priority, lowest first. The three records after this one decide whether that mail is trusted when it arrives.
The single biggest mistake: pointing an MX at a CNAME. MX targets must be A/AAAA, not CNAMEs (RFC 2181 §10.3).
Having no MX record at all does not stop mail: senders fall back to an implicit MX with preference 0 pointing at the domain's A/AAAA record (RFC 5321 §5.1). So if your domain never receives mail, publish a null MX (RFC 7505), a single MX 0 ., so senders bounce immediately instead of retrying for days or delivering to whatever the A record points at.
example.com. MX 0 .Query a live domain's MX records:
dig @1.1.1.1 cloudflare.com MX +short5 mxa-canary.global.inbound.cf-emailsecurity.net.
5 mxb-canary.global.inbound.cf-emailsecurity.net.
10 mxa.global.inbound.cf-emailsecurity.net.
10 mxb.global.inbound.cf-emailsecurity.net.curl -s "https://cloudflare-dns.com/dns-query?name=cloudflare.com&type=MX" \
-H "Accept: application/dns-json" \
| jq '{Status: .Status, AD: .AD, Question: .Question, Answer: .Answer}'{
"Status": 0,
"AD": true,
"Question": [
{
"name": "cloudflare.com",
"type": 15
}
],
"Answer": [
{
"name": "cloudflare.com",
"type": 15,
"TTL": 222,
"data": "5 mxa-canary.global.inbound.cf-emailsecurity.net."
},
{
"name": "cloudflare.com",
"type": 15,
"TTL": 222,
"data": "5 mxb-canary.global.inbound.cf-emailsecurity.net."
},
{
"name": "cloudflare.com",
"type": 15,
"TTL": 222,
"data": "10 mxa.global.inbound.cf-emailsecurity.net."
},
{
"name": "cloudflare.com",
"type": 15,
"TTL": 222,
"data": "10 mxb.global.inbound.cf-emailsecurity.net."
}
]
}AD: true means the resolver validated the DNSSEC chain for this answer (RFC 4035 §3.2.3); you are trusting 1.1.1.1's validation rather than doing your own. type: 15 is the IANA wire type for MX. TTL is in seconds and counts down in the resolver's cache, so the number changes between queries. (The raw API response is a single line of JSON with a few more flags; the jq filter pretty-prints the fields shown.)
nslookup -type=MX cloudflare.com 1.1.1.1Server: 1.1.1.1
Address: 1.1.1.1#53
Non-authoritative answer:
cloudflare.com mail exchanger = 5 mxa-canary.global.inbound.cf-emailsecurity.net.
cloudflare.com mail exchanger = 5 mxb-canary.global.inbound.cf-emailsecurity.net.
cloudflare.com mail exchanger = 10 mxa.global.inbound.cf-emailsecurity.net.
cloudflare.com mail exchanger = 10 mxb.global.inbound.cf-emailsecurity.net.
nslookupcan't show DNSSEC AD-bit status, EDNS details, or follow MX-to-A in one shot. Usedigfor that.
SPF: who's allowed to send from this domain?#
SPF (RFC 7208) is a TXT record at your domain apex. A receiving server checks whether the connecting IP is authorized to send as your domain.
example.com. TXT "v=spf1 include:_spf.mailprovider.com ~all"| Token | Meaning |
|---|---|
v=spf1 | SPF version |
include:_spf.mailprovider.com | Authorize the IPs your mail provider publishes (most hosted mail has a TXT under their own zone that you include) |
~all | Anyone else: soft-fail (mark, don't reject) |
A recipient checks SPF by looking at the envelope MAIL FROM domain (not the visible From: header) and verifying the sending IP is in the allowed set. Bounces arrive with an empty MAIL FROM; the check then uses postmaster@<HELO hostname> instead (RFC 7208 §2.4).
| Final qualifier | Meaning |
|---|---|
-all | Hard fail. Reject. |
~all | Soft fail. Mark suspicious. |
?all | Neutral. Make no decision. |
+all | Pass everything. Don't do this. |
Conservative opening posture is
~allbecause SPF alignment through hosted email is fragile: the envelope-from often resolves to a domain that doesn't visibly match yourFrom:. DKIM picks up that slack via the alignment rules in DMARC.
One SPF record per domain, max. Multiple SPF TXT records at the same name is invalid; receiving servers will (correctly) return permerror (RFC 7208 §4.5). If you need multiple include rules, combine them: v=spf1 include:a include:b ~all.
Query a live SPF record:
dig @1.1.1.1 cloudflare.com TXT +short | grep -i spf"v=spf1 ip4:199.15.212.0/22 ip4:173.245.48.0/20 include:_spf.google.com
include:spf1.mcsv.net include:spf.mandrillapp.com include:mail.zendesk.com
include:stspg-customer.com include:_spf.salesforce.com -all"(the grep isolates the SPF record from the TXT records cloudflare.com publishes, 28 as of 2026-07-28; the count grows as verification tokens accumulate. The single long string is wrapped here for readability)
nslookup -type=TXT cloudflare.com 1.1.1.1Server: 1.1.1.1
Address: 1.1.1.1#53
Non-authoritative answer:
cloudflare.com text = "v=spf1 ip4:199.15.212.0/22 ip4:173.245.48.0/20
include:_spf.google.com include:spf1.mcsv.net include:spf.mandrillapp.com
include:mail.zendesk.com include:stspg-customer.com include:_spf.salesforce.com -all"nslookup cannot filter its output, so running this literally returns every TXT record for cloudflare.com (28 as of 2026-07-28); the block above is trimmed to the one that matters. Scan the output for the line beginning v=spf1: that is the SPF record. On Windows, findstr "v=spf1" pipes the result if you want to isolate it.
This SPF record illustrates two common patterns at once: explicit IP ranges for known sending infrastructure (ip4: blocks), and include: delegation to third-party services (Google Workspace, Mailchimp, Mandrill, Zendesk, Salesforce). The -all hard-fail tells receivers to reject anything not on the list. A domain that sends through many services like this often accumulates include: entries over time; watch the DNS lookup count: the include, a, mx, ptr, and exists mechanisms plus the redirect modifier are capped at 10 per evaluation, and going over is a permerror (RFC 7208 §4.6.4).
DKIM: sign every outbound message#
DKIM (RFC 6376) adds a cryptographic signature in a header (DKIM-Signature: v=1; d=example.com; s=selector1; ...). The recipient:
- Reads the
d=(domain) ands=(selector) from the header. - Queries
<selector>._domainkey.<domain>for the public key. - Verifies the signature against the body and signed headers.
Most hosted mail providers publish DKIM via a CNAME so they can rotate the underlying key without touching your zone:
selector1._domainkey.example.com. CNAME selector1.mailprovider.example.Self-managed mail publishes the key as a TXT directly:
selector1._domainkey.example.com. TXT "v=DKIM1; k=rsa; p=ABC...XYZ"DKIM TXT records often exceed the 255-byte per-string limit and need to be split into multiple quoted strings; the verifier concatenates them before use (RFC 6376 §3.6.2.2).
Query a real DKIM public key (Proton Mail's protonmail selector on proton.me):
dig @1.1.1.1 protonmail._domainkey.proton.me TXT +shortprotonmail.domainkey.drfeyjwh4gwlal4e2rhajsytrp6auv2nhenecpzigu7muak6lw6ya.domains.proton.ch.
"v=DKIM1;k=rsa;p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAn46zCm3zHBmS
1zePKxA+RIXw41Nu6l91NpLLBnWnrcZ35H/843XNWPZEQ0OgGwx/yqTXETMLXIDjGEWlK1E1
mpdguqu+3s7SuIHoo5+i6mgyxJguljkwc3dk8ojnJ6VVUPnDh5GJArkAhXxEb1aOK1BVGM0y
DlmYdmaOfd48qcx5iODP/MFc8pivfxEXTIL+aUz"
"7+X69lMiwUSHpWYL3/a5X3nLD0zEntxv08xs8J/rpuRg4v+OXEOhcNvhkeiRZqJBdpJTkoEZ
fGvdTct+U0YYC69NW0ClUcKio2uDPmxU1xvfvHbSTW2gHYk8RpYZaxLACULdMo+Vt4Na/oIR
+swIDAQAB;"The first output line is the CNAME delegation described above, live: protonmail._domainkey.proton.me points into Proton's own domains.proton.ch zone, so Proton can rotate the key without touching the proton.me zone file. The TXT at the end of the chain carries the key.
nslookup -type=TXT protonmail._domainkey.proton.me 1.1.1.1Server: 1.1.1.1
Address: 1.1.1.1#53
Non-authoritative answer:
protonmail._domainkey.proton.me canonical name =
protonmail.domainkey.drfeyjwh4gwlal4e2rhajsytrp6auv2nhenecpzigu7muak6lw6ya.domains.proton.ch.
protonmail.domainkey.drfeyjwh4gwlal4e2rhajsytrp6auv2nhenecpzigu7muak6lw6ya.domains.proton.ch
text = "v=DKIM1;k=rsa;p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAn46zCm3zHBmS
1zePKxA+RIXw41Nu6l91NpLLBnWnrcZ35H/843XNWPZEQ0OgGwx/yqTXETMLXIDjGEWlK1E1
mpdguqu+3s7SuIHoo5+i6mgyxJguljkwc3dk8ojnJ6VVUPnDh5GJArkAhXxEb1aOK1BVGM0y
DlmYdmaOfd48qcx5iODP/MFc8pivfxEXTIL+aUz"
"7+X69lMiwUSHpWYL3/a5X3nLD0zEntxv08xs8J/rpuRg4v+OXEOhcNvhkeiRZqJBdpJTkoEZ
fGvdTct+U0YYC69NW0ClUcKio2uDPmxU1xvfvHbSTW2gHYk8RpYZaxLACULdMo+Vt4Na/oIR
+swIDAQAB;"The key is split into two quoted strings because the RSA public key data exceeds the 255-byte per-string wire limit (the first string here is exactly 255 bytes). The DKIM verifier concatenates them before decoding the key; the resolver hands them over as-is, which is why dig shows two separate strings.
Retired DKIM keys do not just disappear: providers publish the selector with an empty key (
p=), which tells verifiers the key is revoked (RFC 6376 section 3.6.1). Gmail's old20230601selector returns exactly that today, so an emptyp=in the wild is deliberate, not broken.
nslookupcannot verify the DKIM signature itself, show the selector, or display EDNS details. Usedigfor troubleshooting.
DMARC: what do I do if SPF or DKIM fails?#
DMARC (originally RFC 7489, 2015, Informational; obsoleted by RFC 9989, published May 2026 as a Proposed Standard, with reporting split out into RFC 9990 for aggregate and RFC 9991 for failure reports) ties SPF + DKIM to the visible From: header and gives the recipient a policy.
_dmarc.example.com. TXT "v=DMARC1; p=none; adkim=r; aspf=r"Receivers look the record up at _dmarc. prefixed to the From: domain; if nothing is there, they fall back to the organizational domain's record (RFC 7489 derived that from the Public Suffix List, RFC 9989 §4.10.1 replaces it with a bounded DNS tree walk).
| Tag | Meaning |
|---|---|
v=DMARC1 | DMARC version |
p=none | Policy: don't take action. Monitor only. |
p=quarantine | Send failing mail to spam |
p=reject | Hard-reject failing mail at SMTP time |
sp= | Policy for subdomains; if absent, subdomains inherit p= |
np= | Policy for non-existent subdomains (new in RFC 9989) |
adkim=r | DKIM alignment: relaxed (subdomain matches parent OK) |
adkim=s | DKIM alignment: strict (must match exactly) |
aspf=r/s | Same, for SPF alignment |
rua=mailto: | Where to send aggregate reports (XML, daily) |
ruf=mailto: | Where to send failure reports (detailed, per-message). Note: few large receivers send ruf= reports in practice; most stop at rua= aggregates. |
pct=N | Apply policy to N% of failing mail (staged rollout). RFC 7489 only: RFC 9989 removed pct, though records in the wild still carry it |
A typical opening posture: p=none, announce DMARC is in play, ask the receiver to check, but don't ask for action. Once you confirm DKIM signatures align consistently (a few days of rua= reports proves it), promote to p=quarantine. Skipping straight to p=reject is a common way to discover a forgotten legitimate sender by losing its mail in production.
Query a live DMARC record at a p=reject domain:
dig @1.1.1.1 _dmarc.cloudflare.com TXT +short"v=DMARC1; p=reject; sp=reject; adkim=r; aspf=r; pct=100;
rua=mailto:<token>@dmarc-reports.cloudflare.net,
mailto:rua@cloudflare.com"nslookup -type=TXT _dmarc.cloudflare.com 1.1.1.1Server: 1.1.1.1
Address: 1.1.1.1#53
Non-authoritative answer:
_dmarc.cloudflare.com text = "v=DMARC1; p=reject; sp=reject; adkim=r;
aspf=r; pct=100;
rua=mailto:<token>@dmarc-reports.cloudflare.net,
mailto:rua@cloudflare.com"This is what a fully-enforced DMARC policy looks like: p=reject with pct=100 means every unauthenticated message that claims From: @cloudflare.com is rejected at SMTP time, and sp=reject applies the same policy to subdomains. The adkim=r; aspf=r pair spells out relaxed alignment, which is also the default when the tags are absent.
Forge resistance: cheapest to strongest#
| Step | What it costs | What it stops |
|---|---|---|
| Just MX | nothing | Nothing, anyone can claim to send "from" you |
MX + SPF ~all | one TXT record | A casual spammer with a different sending IP than yours |
| MX + SPF + DKIM | DKIM setup, key rotation | Tampering in transit: any change to the signed headers or body breaks the signature |
MX + SPF + DKIM + DMARC p=none | one TXT record | Nothing yet, but you start getting reports if you publish rua= |
Same + DMARC p=quarantine | nothing | Most forgeries, they land in spam at major receivers |
Same + DMARC p=reject | nothing | Exact-domain forgeries: receivers that honor the policy reject at SMTP time |
The jump from p=none to p=quarantine is usually where domains stall, because legitimate mail-forwarding, mailing lists, and old infrastructure often break alignment. Watch reports first. And note the scope: DMARC protects your exact domain and its subdomains; lookalike domains (examp1e.com) are outside its reach entirely.
BIMI: the optional cosmetic on top#
Once you're at p=quarantine or p=reject, you can publish a BIMI record that lets supporting clients (Gmail, Apple Mail, others) show your logo next to messages. It takes an SVG logo (SVG Tiny PS profile) plus, in practice, a certificate proving you own the mark: Gmail displays logos only with a VMC or CMC and only at full enforcement, and Apple Mail likewise requires certificate-backed evidence such as a VMC. The BIMI spec calls the certificate optional; the two clients that matter do not.
Reading the headers in a received message#
When debugging:
- Open a message in your mail client and use "show raw headers" or "show original."
- Find
Authentication-Results:: it listsspf=pass/fail,dkim=pass/fail,dmarc=pass/fail. - If DMARC says
policy=noneand result isfail, the message would have been quarantined under stricter policy.
Authentication-Results: mx.recipient.example;
spf=pass smtp.mailfrom=example.com;
dkim=pass header.d=example.com header.s=selector1;
dmarc=pass action=none header.from=example.com
nslookupcan show you the SPF, DKIM, and DMARC TXT records, but it cannot parse or validate them. TheAuthentication-Resultsheader is written by the receiving MTA after it does that work.
Gotchas#
- MX targets must be A/AAAA, never a CNAME (RFC 2181 §10.3).
- No MX does not stop mail. Senders fall back to the implicit MX at the domain's A/AAAA (RFC 5321 §5.1); a no-mail domain needs the null MX (RFC 7505).
- SPF caps DNS lookups at 10 per evaluation;
include:sprawl past the cap is apermerror(RFC 7208 §4.6.4). - A DKIM TXT split into multiple quoted strings is normal, and an empty
p=is a revoked key, not a broken record (RFC 6376 §3.6.1). - DMARC covers your exact domain and its subdomains only. Lookalike domains (
examp1e.com) are outside its reach. pct=is gone from the current spec. RFC 9989 removed it; records in the wild still carry it.