MX, SPF, DKIM, DMARC: the four records every mail-receiving domain 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. None of them are optional once you start receiving mail at a public domain.
The four records, at a glance#
| 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.
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.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 26 TXT records cloudflare.com publishes at the time of writing; 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 (26 today); 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.
See also: MX record reference · TXT record reference · PTR: why reverse DNS matters for outbound mail · CAA: locking down cert issuance for the mail server · CNAME: why DKIM often uses one · nslookup and dig: the two tools you will actually use