All posts Deliverability

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.

Erik Vlčák
Erik Vlčák
Customer Success Engineer
10 min read

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 ~all

Breaking it down:

  • v=spf1: version identifier, always spf1
  • include:spf.lettr.com: authorize all IPs in Lettr's SPF record
  • include:_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 +short

Look 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 responsibility
  • s=lettr: the selector indicating where receivers can locate the public key
  • h=from:to:subject:date:message-id: the headers included in the signature
  • bh=...: the hash of the message body
  • b=...: 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 +short

You'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=100

The fields:

  • v=DMARC1: version
  • p=none: policy (what to do with failing messages)
  • rua=mailto:dmarc-reports@yourapp.com: where to send aggregate reports
  • pct=100: percentage of failing messages the policy applies to

Check it with:

dig TXT _dmarc.yourapp.com +short

DMARC 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 From header domain or is a subdomain of it.
  • DKIM alignment: the d= domain in the DKIM signature matches the From header 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.com

Without 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.com

This 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.com

Once 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.com

Now, 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 +short

Then 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.com

You 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 include directive.
  • 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 From domain. 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.