A user signs up for your side project, forgets the password a week later, and clicks "reset". Nothing arrives. They check spam, find your email sitting there next to a crypto giveaway, and decide your product is not worth the trouble. You never hear about it. The logs say the provider accepted the message, so as far as your app knows, everything worked.

Often the cause is three DNS records you never added. Gmail and Yahoo started enforcing sender requirements in February 2024, and Google's FAQ says that "starting November 2025, Gmail is ramping up its enforcement on non-compliant traffic," with temporary and permanent rejections for mail that misses the bar.

What Gmail and Yahoo actually require

The rules have two tiers. Read the tier you are actually in, not a vendor summary.

Every sender, including a side project sending ten emails a day, must set up SPF or DKIM, use DKIM keys of at least 1024 bits (2048 recommended), have valid forward and reverse DNS on the sending IP, use TLS, and keep the spam rate in Postmaster Tools below 0.3%. Yahoo's version says the same: "Implement SPF or DKIM at a minimum."

Bulk senders, defined by Google as more than 5,000 messages a day to Gmail, must have both SPF and DKIM, publish a DMARC record (p=none is enough), align the From: domain with the SPF or DKIM domain, and support one-click unsubscribe on marketing mail. Yahoo adds that "DMARC must pass."

So a small app is not a bulk sender by Google's definition. Set up all three anyway. DMARC is what tells a receiver that the domain in the visible From: address is the one that authenticated. And if you ever cross the bulk line, Google says new domains are enforced on an accelerated timetable. Your transactional provider already handles TLS and reverse DNS on its own IPs. The DNS records are on you.

An envelope sealed with three wax seals, each stamped with a different abstract key shape
Three records, three seals. DMARC is the one that checks the other two belong to your domain.

Record 1: SPF, on the domain that actually bounces

SPF (RFC 7208) lists which servers may send mail for a domain. The part most guides skip: SPF checks the envelope sender, the MAIL FROM or Return-Path, not the From: header the user sees. The RFC says plainly that "the 'MAIL FROM' and 'HELO' identities are checked."

That matters because transactional providers put their own domain in the Return-Path by default, so they can process bounces. Amazon SES uses a subdomain of amazonses.com unless you configure a custom MAIL FROM domain. Postmark's default is pm.mtasv.net. SPF passes, but for the provider's domain, which does nothing for DMARC on yours.

The fix is a custom bounce subdomain. Every provider has a name for it (custom MAIL FROM, custom Return-Path, bounce domain) and gives you the exact records. Two real shapes:

; SES custom MAIL FROM (region filled in by the console) bounce.example.com MX 10 feedback-smtp.us-east-1.amazonses.com bounce.example.com TXT "v=spf1 include:amazonses.com ~all" ; Postmark custom Return-Path pm-bounces.example.com CNAME pm.mtasv.net 

Both come straight from the provider docs linked above. With SES you publish the SPF record yourself. With Postmark the CNAME points at a host whose SPF the provider maintains.

If you also send from the root domain (your own hosted inbox, say), that domain gets its own SPF record. Two rules from the RFC will save you an afternoon. A domain may have exactly one v=spf1 record; with more than one, the result is permerror. And evaluation is capped at 10 DNS-querying terms (include, a, mx, ptr, exists, redirect); go over and you get permerror again. Stack four providers' include: chains and you can cross that line without noticing.

Verify with dig:

dig +short TXT bounce.example.com dig +short TXT example.com | grep spf1 

You want exactly one string starting with v=spf1 per name. If the second command prints two lines, merge them into one record before doing anything else.

Record 2: DKIM, the signature that survives forwarding

DKIM (RFC 6376) signs each message with a private key. The receiver reads the d= (domain) and s= (selector) tags from the DKIM-Signature header and fetches the public key from <selector>._domainkey.<domain>. As the SES DMARC guide points out, the signature travels inside the message, so it still verifies after a forward that breaks SPF.

Your provider gives you the selector and the record. Depending on the provider it is either a TXT record holding the key or a CNAME pointing at a key the provider hosts. Either way, the check is the same:

# replace "s1" with the selector your provider gave you dig +short TXT s1._domainkey.example.com 

A TXT record should come back starting with v=DKIM1 and containing a long p= value. If it is a CNAME, dig follows it and prints the target name and then the key. An empty p= means the key has been revoked and every signature will fail. A 2048-bit key split into several quoted strings is normal, though: verifiers join the strings back together.

DKIM is also where alignment usually comes from. When the provider signs with d=example.com, DKIM aligns with your From: domain even if SPF does not.

Record 3: DMARC, starting at p=none

DMARC lives at _dmarc.<domain>. It tells receivers what to do with mail that fails alignment and where to send reports. Start in monitor mode:

_dmarc.example.com TXT "v=DMARC1; p=none; rua=mailto:dmarc@example.com" 

p=none asks receivers to deliver as usual. rua is where aggregate reports go; per RFC 9989, if it is missing, receivers "MUST NOT generate aggregate feedback reports," which means you have no data for the next step. Leave adkim and aspf out: the default is relaxed alignment, which lets bounce.example.com align with example.com.

dig +short TXT _dmarc.example.com 

One record, starting with v=DMARC1. DMARC itself changed this year. RFC 9989, published in May 2026, obsoletes RFC 7489. It drops the pct tag, adds t= for test mode and np= for non-existent subdomains, and existing v=DMARC1 records keep working.

Two keys, an envelope-shaped one and a signature-shaped one, both fitting the same house-shaped lock
Alignment: the domain that passed SPF or DKIM has to match the From address.

Send one email and read the header

DNS can look perfect and the message can still fail, usually because the app sends from a From: address on a domain you did not set up. Trigger your real password-reset flow to a Gmail address you own. Open the message, click More, then Show original, as described in Gmail's header guide. Find the Authentication-Results header (RFC 8601). With placeholders swapped in, a healthy one has this shape:

Authentication-Results: mx.google.com; dkim=pass header.i=@example.com; spf=pass smtp.mailfrom=bounce.example.com; dmarc=pass header.from=example.com 

Read it top to bottom:

  • dkim=pass header.i=@example.com — the signature verified, and the signing domain is yours. If it says @ followed by the provider's domain, the provider signed with its own key. Go back to its DKIM setup.
  • spf=pass smtp.mailfrom=bounce.example.com — the envelope sender is your bounce subdomain. If smtp.mailfrom shows the provider's domain, SPF passed but cannot align. Fine if DKIM aligns; better if both do.
  • dmarc=pass header.from=example.com — at least one of the two passed and aligned with the visible From: domain. This is the line that matters.

Any fail, softfail, permerror or none points you back to the matching dig command above. permerror on SPF usually means two records or more than ten lookups.

Moving from p=none to quarantine without losing mail

RFC 9989 is specific about pacing: domains heading for p=reject should start by "publishing 'p=none' for at least a month, followed by publishing 'p=quarantine'" for a similar period (section 7.4). AWS's rollout advice follows the same order.

Spend that month reading the rua reports. They are XML files listing every source IP that sent as your domain and whether it passed. Look for legitimate senders you forgot: the newsletter tool, the CRM, the help desk that emails customers "from" your domain, the invoice system. Each one needs DKIM on your domain or its own aligned bounce domain before you tighten anything. Anything left failing should be mail you do not recognize.

Then change one line:

_dmarc.example.com TXT "v=DMARC1; p=quarantine; rua=mailto:dmarc@example.com" 

Run dig +short TXT _dmarc.example.com again until the new value shows up, send another reset email, and confirm dmarc=pass is still there. Keep reading reports.

Two cautions on the old gradual-rollout tricks. pct=10 was meant to quarantine a slice of failing mail, but RFC 9989 removed it because "operational experience showed that the 'pct' tag was usually not accurately applied" except at 0 or 100. Its replacement, t=y, asks receivers to apply the next-weaker policy, so p=quarantine; t=y behaves like p=none. Only receivers that implement RFC 9989 honor it. What protects you is a month of clean reports.

If this was useful

Email authentication is one of those chores that sits between "it works on localhost" and "real users depend on it," and nobody tells you about it until reset emails start vanishing. Ship It is my book about that whole gap for small teams: hosting, deploys, auth, payments, CI/CD and monitoring, picked for the stage you are actually at. If this checklist saved you a support ticket, the rest of the book is the same kind of checklist for everything else.

Ship It — the pragmatic startup tech stack