SPF, DKIM, and DMARC explained for developers
Open a terminal and you can send mail claiming to be from support@stripe.com — SMTP won't stop you. Three DNS-based standards close that gap. Here's what each one does and how to tell if it's working.

Open a terminal, and you can send an email claiming to be from support@stripe.com.
SMTP won't stop you. The protocol was designed in the early 1980s with no built-in way to verify
the sender, and that gap is still there.
Three standards have been retrofitted on top to close it: SPF, DKIM, and DMARC. Together, they decide whether mail claiming to be from your domain actually deserves to land in someone's inbox. Most guides on this topic are written for IT or network admins. This one is for developers who just want to know what these DNS records do, how they fit together, and how to tell whether they're working.
The 30-second mental model
Before the details, the shape of the system is as follows:
- SPF is an IP allowlist. A DNS record that lists which servers are allowed to send mail for your domain.
- DKIM is a cryptographic signature. Your provider signs each outgoing message with a private key, and receivers verify it using a public key in your DNS.
- DMARC is the policy layer. It tells receivers what to do when SPF and DKIM fail and where to send reports about it.
SPF and DKIM each answer "Is this message legitimate?" in different ways. DMARC sits on top, ties
them to the From header users actually see, and provides a feedback loop. The rest of
this article explains what each part means in practice.
SPF: declaring who can send for you
Sender Policy Framework is the simplest of the three: a DNS TXT record that lists which IP addresses are allowed to send email on behalf of your domain.
Think of it as a guest list. When a receiving server receives a message claiming to be from yourapp.com, it looks up that domain's SPF record and checks whether the sending IP
is on the list. If not, the message fails SPF.
A typical SPF record looks like this:
v=spf1 include:spf.lettr.com include:_spf.google.com ~allBreaking it down:
v=spf1: version identifier, alwaysspf1include:spf.lettr.com: authorize all IPs in Lettr's SPF recordinclude:_spf.google.com: also authorize Google Workspace IPs (if you use Gmail for corporate mail)~all: soft-fail anything else (see below)
The all mechanism is the catch-all verdict for IPs that didn't match earlier rules. -all hard-fails (rejects), ~all soft-fails (accepts but marks suspicious),
and ?all is neutral. Most setups use ~all. A hard fail will bounce legitimate
mail on the day you forget to include a service, and that day always comes.
Check your record with dig:
dig TXT yourapp.com +shortLook for the entry that starts with v=spf1. If you see two, that's already a bug. The
spec allows exactly one, as multiple records produce undefined behavior because receivers may evaluate
either.
SPF's limitations
SPF checks the envelope sender (the MAIL FROM in the SMTP conversation), not the From header that users actually see. Those can be completely different addresses. A phishing
email can fail SPF on its real sending domain but still display any From address it wants
in the header. Users only see the header, which alone is why SPF can't be the whole story.
SPF also fails when forwarding is involved. If old@company.com forwards to personal@gmail.com, the forwarder's IP isn't included in your SPF record, so the message
fails even though it's legitimate. Mailing lists and corporate forwards do this constantly.
Then there's the 10-DNS-lookup limit. Every include, a, mx,
and redirect mechanism counts. Nested includes count as well: if include:spf.lettr.com includes two other domains, that's three lookups before you've
added anything else. If the lookup count exceeds 10, the whole evaluation returns permerror, which most receivers treat as a failure. Trace your lookup count with online
tools or recursively with dig. Trim unused services aggressively.
DKIM: signing each message cryptographically
DomainKeys Identified Mail takes a different approach. Instead of checking server IPs, it signs each outgoing message with a private key. The public key is published in DNS so any receiving server can verify the signature.
When your provider sends a message, it computes a hash of selected headers and the body, then
signs the hash with the private key. The signature is included in a DKIM-Signature header
on the message:
DKIM-Signature: v=1; a=rsa-sha256; d=yourapp.com; s=lettr;
h=from:to:subject:date:message-id;
bh=2jUSOH9NhtVGCQWNr9BrIAPreKQjO6Sn7XIkfJVOzv8=;
b=AuUoFEfDxTDkHlLXSZEpZj79LICEps6eda7W3deTVFOk2...The fields that matter:
d=yourapp.com: the domain that claims responsibilitys=lettr: the selector indicating where receivers can locate the public keyh=from:to:subject:date:message-id: the headers included in the signaturebh=...: the hash of the message bodyb=...: the actual signature
To verify, the receiver looks up the public key at <selector>._domainkey.<domain> (in this case, lettr._domainkey.yourapp.com), recalculates the hash, and compares it. If anything was
tampered with along the way, even a single character in the subject or body, the signature won't match.
You can fetch the public key yourself:
dig TXT lettr._domainkey.yourapp.com +shortYou'll see something like:
"v=DKIM1; k=rsa; p=MIGfMA0GCSqGSIb3DQEBAQUAA4..."The selector exists specifically to enable key rotation. Providers publish a new key under a new
selector (lettr2._domainkey...), start signing with it, and retire the old key once mail
in flight has cleared.
Why DKIM matters more than SPF
DKIM survives forwarding. The signature is attached to the message itself, so it doesn't matter how many hops it takes. As long as no relay rewrites the signed headers or body, the signature still verifies. That's its main advantage over SPF.
DKIM also signs the visible From header, not just the envelope sender. It directly protects
the address users actually see, making convincing spoofing much harder.
The trade-off is that you don't manage any of this yourself. Your provider generates the keys, signs outgoing messages, and provides you with the DNS records to publish. When you add a sending domain in Lettr, DKIM records are generated automatically. You copy them to your DNS, and you're done.
DMARC: policy and reporting
Domain-based Message Authentication, Reporting, and Conformance (DMARC) ties SPF and DKIM together. Without it, a message can fail both checks and still land in the inbox, as each receiver decides for itself. DMARC lets the domain owner publish a policy that answers: "If something claiming to be from my domain fails authentication, what should you do with it?" It also provides a feedback channel so you can see what's actually happening.
A DMARC record is a DNS TXT record at _dmarc.yourapp.com:
v=DMARC1; p=none; rua=mailto:dmarc-reports@yourapp.com; pct=100The fields:
v=DMARC1: versionp=none: policy (what to do with failing messages)rua=mailto:dmarc-reports@yourapp.com: where to send aggregate reportspct=100: percentage of failing messages the policy applies to
Check it with:
dig TXT _dmarc.yourapp.com +shortDMARC alignment
This is the concept that makes the whole system work. A message passes DMARC if it passes either
SPF or DKIM, and the domain that passes aligns with the From header domain
users see.
Two flavors of alignment:
- SPF alignment: the envelope sender domain matches the
Fromheader domain or is a subdomain of it. - DKIM alignment: the
d=domain in the DKIM signature matches theFromheader domain.
This is what closes the gap that SPF left wide open. Earlier, we noted that SPF only checks the
envelope, not the From header, so an attacker could pass SPF checks on their own domain
and still display your domain in the From header. DMARC alignment causes that to fail.
Passing SPF or DKIM for the wrong domain no longer counts. The authenticated domain must match what
the user sees.
Don't forget subdomains
A DMARC record for yourapp.com covers the apex by default, and subdomains inherit unless
you specify otherwise. The sp= tag lets you set a separate policy for subdomains:
v=DMARC1; p=reject; sp=reject; rua=mailto:dmarc-reports@yourapp.comWithout sp=, subdomains fall back to whatever p= specifies. That's usually
fine, but attackers love spoofing subdomains like mail.yourapp.com or support.yourapp.com because organizations often forget to authenticate them. If you have
legitimate subdomains that send mail (e.g., notifications.yourapp.com), they need their
own SPF and DKIM setup. Otherwise, sp=reject will block them.
The three stages of deployment
DMARC deployment should be gradual. We have seen teams jump straight to p=reject and lose
entire categories of transactional mail overnight, including forwarded messages, messages from unauthorized
third parties, and messages from misconfigured subdomains.
Stage 1: p=none (monitor only)
v=DMARC1; p=none; rua=mailto:dmarc-reports@yourapp.comThis tells receivers to send you reports but to take no action on failed messages. Deploy it first and review the reports for a few weeks. You'll discover every service sending mail from your domain, some you expected, some you didn't.
Aggregate reports arrive as XML files (usually gzipped) at the rua address:
<record>
<row>
<source_ip>198.51.100.42</source_ip>
<count>1523</count>
<policy_evaluated>
<disposition>none</disposition>
<dkim>pass</dkim>
<spf>fail</spf>
</policy_evaluated>
</row>
<identifiers>
<header_from>yourapp.com</header_from>
</identifiers>
</record>That record says: 1,523 messages from IP 198.51.100.42 claimed to be from yourapp.com; DKIM passed, SPF failed. You'd then look up the IP to determine whether
it's a legitimate sender (maybe a forwarder) or someone spoofing you.
Stage 2: p=quarantine (mark as suspicious)
v=DMARC1; p=quarantine; pct=25; rua=mailto:dmarc-reports@yourapp.comOnce your legitimate senders pass authentication, move to quarantine. Use pct to ramp up: start by quarantining 25% of failing messages (usually routed to spam),
then increase to 100% as confidence grows.
One caveat: pct is being deprecated. Some major receivers (notably Google since 2024) ignore
it when p=reject and treat it as 100%. It still works for p=quarantine, but
plan for short staging periods rather than long partial rollouts.
Stage 3: p=reject (block outright)
v=DMARC1; p=reject; rua=mailto:dmarc-reports@yourapp.comNow, receivers reject messages that fail DMARC, so they won't be delivered at all. Be confident that every legitimate sender is properly authenticated before you flip this switch.
The full progression usually takes 4–8 weeks, depending on the number of sending services you have and how quickly you can verify each one.
Forwarding, mailing lists, and ARC
DMARC has one persistent enemy: forwarding. Mailing lists rewrite subjects ([my-list] ...), append footers, or modify headers, and any of those break DKIM. SPF is already broken by
forwarding, so nothing remains to authenticate.
Authenticated Received Chain (ARC) is the workaround. Each forwarder adds a signed seal that records the auth results it observed before touching the message. The next hop can trust that chain even when SPF and DKIM no longer pass directly.
Most large receivers (Gmail, Microsoft 365) honor ARC. You don't need to configure anything on
your sending domain (sealing is the forwarder's job), but this explains why some forwarded mail
clears p=reject while other forwarded mail doesn't.
The takeaway: before you reach p=reject, monitor your DMARC reports for failures from
forwarding sources. You may need to nudge mailing list operators toward ARC or move that traffic to
a different domain.
Verifying everything is working
Once all three records are in DNS, confirm they're actually doing what you expect. Start with dig:
# Check SPF
dig TXT yourapp.com +short | grep spf
# Check DKIM (replace 'lettr' with your selector)
dig TXT lettr._domainkey.yourapp.com +short
# Check DMARC
dig TXT _dmarc.yourapp.com +shortThen send yourself a test email and inspect the headers. In Gmail, open the message and click
"Show original." The header you want is Authentication-Results, added by the recipient:
Authentication-Results: mx.google.com;
dkim=pass header.d=yourapp.com header.s=lettr;
spf=pass (google.com: domain of bounce+abc123@mail.yourapp.com
designates 192.0.2.1 as permitted sender)
smtp.mailfrom=bounce+abc123@mail.yourapp.com;
dmarc=pass (p=REJECT dis=NONE) header.from=yourapp.comYou want dkim=pass, spf=pass, and dmarc=pass. If any show fail or softfail, here's how to trace the issue:
- SPF fail → the sending IP isn't listed in your record. Identify the server that
actually sent the message and add its IP or an
includedirective. - DKIM fail → the signature didn't verify. Usually, a DNS record is incorrect, a key was rotated without updating DNS, or an intermediary (often a mailing list) modified the message in transit.
- DMARC fail → either both SPF and DKIM failed, or neither aligned with the
Fromdomain. Check alignment first; mismatched domains are the more common cause.
If you're using Lettr, the domain verification flow runs these checks for you and flags any missing records before you start sending.
Common mistakes
Forgetting a sending service. You set up SPF for your primary provider but missed that
your billing system sends invoices through a different provider. Those emails quietly fail, and at p=reject they disappear. The p=none phase exists to catch exactly this.
Not reading DMARC reports. Pointing rua at an address nobody checks defeats
the purpose. DMARC reports tell you who's sending from your domain, whether it's a service you forgot
about or someone overseas spoofing you. Either way, you want to know. Use a DMARC reporting tool if XML
isn't your idea of a good time.
Expired or rotated DKIM keys. Providers rotate keys; if your DNS record becomes stale (or someone deletes it because "we don't use that anymore"), DKIM silently fails. Stage 1 monitoring catches this at later stages. Customers stop receiving mail with no obvious cause.
Overly broad SPF includes. include:_spf.google.com when you don't use
Google Workspace, or stale entries for services you stopped years ago. Each one expands the set of IPs
authorized to send as you and consumes a slot toward the 10-lookup limit.
Treating p=quarantine as good enough. Quarantined mail still ends up in
spam folders, where users see it and sometimes interact with it. p=reject is the only setting
that actually prevents spoofed mail from reaching the recipient.
What's next: BIMI
Once you're at p=quarantine or p=reject, you can publish a BIMI record (Brand
Indicators for Message Identification). It tells supporting clients (Gmail, Yahoo Mail, and Apple Mail)
to display your logo next to authenticated messages.
Most clients require a Verified Mark Certificate (VMC) tied to a registered trademark, even though it costs money and isn't worth it for most teams. However, for consumer brands whose customers are often phished, the visible signal is worth the cost. It's not part of authentication itself (DMARC enforcement is the prerequisite), but it's the natural next step once your domain is secured.
None of this is optional anymore
Gmail and Yahoo both require all three for bulk senders, and inbox providers increasingly penalize domains that lack them. Without these records, anyone can send an email that appears to come from your domain, and your users won't examine the headers. They'll assume it was you.
The actual work involves a handful of DNS records and a few weeks of monitoring. Set up SPF, let
your provider handle DKIM, deploy DMARC with p=none, review the reports, and ratchet up
enforcement as confidence grows.