DevKitLab Logo DevKitLab
Email / SPF / DKIM / DMARC / DNS

SPF, DKIM, and DMARC: What Each One Actually Does

Three records, three different jobs. What SPF, DKIM, and DMARC each check, why alignment is the piece that makes DMARC work, and how a message can pass SPF and still fail DMARC.

Someone forwards you a screenshot: an invoice email, your company’s name in the From line, a payment link that isn’t yours. You check the sending server logs. Nothing went out. The message never touched your infrastructure — the attacker simply typed your domain into the From field, the same way you can write any return address on an envelope.

That’s the hole SPF, DKIM, and DMARC exist to close. They’re usually named in one breath, as if they were three versions of the same idea, and that framing is exactly what makes them confusing. They’re not redundant and they’re not interchangeable. Each answers a different question, and only the third one is about the address your recipients actually see.

The two “from” addresses nobody tells you about

Before the records make sense, one piece of plumbing has to be clear, because almost every confusing DMARC result traces back to it: an email has two sender addresses, and they don’t have to match.

  • The envelope sender — also called MAIL FROM or, once delivered, the Return-Path header. This is SMTP-level: the address the sending server declares during the handshake, and where bounces go. Your recipient never sees it.
  • The From: header — the one rendered in the mail client. This is what a human reads and trusts.

They’re independent fields. A newsletter you legitimately sent through a marketing platform typically has an envelope sender at bounces.mailer-provider.com and a From: header at yourcompany.com — perfectly normal, not a forgery. But the gap is also the attacker’s whole opportunity: pass authentication on a domain you control, display a domain you don’t.

Hold onto that distinction. It’s the reason SPF alone can’t stop spoofing, and the reason DMARC needed to invent alignment.

SPF: which servers may send for this domain

SPF (Sender Policy Framework, RFC 7208) publishes a list of IP addresses allowed to send mail for a domain. You put it in DNS as a TXT record:

v=spf1 include:_spf.google.com include:sendgrid.net ip4:198.51.100.25 -all

Read left to right: mail from Google Workspace’s ranges passes, mail from SendGrid’s ranges passes, mail from that one server passes, and -all says everything else fails. The receiving server takes the connecting IP, walks the list, and returns a result.

That final mechanism carries more weight than its size suggests:

EndingNameMeaning
-allfailNot authorized. The strict, intended setting.
~allsoftfailNot authorized, but accept and mark it. A staging setting.
?allneutralNo opinion — barely different from publishing nothing.
+allpassEveryone is authorized. Never publish this.

Three limits matter in practice. First, SPF has a budget of ten DNS-querying terms (RFC 7208 §4.6.4). include, a, mx, ptr, exists, and redirect each spend one, and include spends recursively — so four or five SaaS providers can quietly push you over. Past ten, evaluation returns PermError, which counts as an SPF failure. Nothing about the record looks broken; it just stops working for some senders.

Second, and more fundamental: SPF authenticates the envelope sender, not the From: header. An attacker registers a throwaway domain, publishes a perfectly valid SPF record for it, sends from their own server, and passes SPF — while the From: header says your company. SPF did its job correctly. Its job was simply never to protect the address the reader sees.

Third, a practical wrinkle: SPF breaks on forwarding. When a recipient auto-forwards your message, the forwarding server relays it from its IP, which isn’t in your record. SPF fails through no fault of yours.

To see your own budget expanded — including the includes-inside-includes that consume it — paste your domain into the SPF and DMARC checker. It walks the whole tree and counts every lookup-costing term. Read that total as a ceiling rather than a verdict: evaluation stops at the first mechanism that matches, so going over ten breaks the sending paths that reach past the tenth lookup — not every message you send.

DKIM: a signature that survives the trip

DKIM (DomainKeys Identified Mail, RFC 6376) attaches a cryptographic signature to the message itself. The sending server signs a set of headers plus the body with a private key and adds a DKIM-Signature: header. The public key lives in DNS. The receiver fetches it and verifies.

The signature header names its own key location:

DKIM-Signature: v=1; a=rsa-sha256; d=example.com; s=selector1;
  h=from:to:subject:date; bh=...; b=...

d= is the signing domain, s= is the selector. Together they point at the DNS name selector1._domainkey.example.com, where the public key sits. Splitting the key location into a selector is what lets one domain run several keys at once — one per provider, or an old and new key during rotation.

Because the proof travels inside the message, DKIM survives relaying: forward a signed message and the signature still verifies, as long as nothing changed. That’s the flip side — DKIM breaks on modification. Mailing lists that append a footer or rewrite the subject invalidate the signature they pass through.

Two details worth knowing when reading a signature: h= lists exactly which headers were signed, and a signature that doesn’t cover From is close to worthless — the attacker can swap the visible sender without disturbing it. And l= sets a body-length limit, signing only the first N bytes; content appended past that point is unsigned and unnoticed.

One asymmetry to keep in mind when auditing a domain: DNS offers no way to list the selectors a domain publishes. You can look up a selector you already know, but you cannot enumerate them. Any tool reporting on DKIM is probing common names or asking you for yours — so “no key found” means “none of the names I tried matched,” never “this domain has no DKIM.”

DMARC: tie the checks to the visible sender

Here’s where the first two get useful. SPF authenticates the envelope. DKIM authenticates a signing domain. Neither one is required to have anything to do with the From: header — and the From: header is the only part the recipient reads.

DMARC (RFC 9989) closes that gap with a rule called alignment: an SPF or DKIM pass only counts if the domain it authenticated matches the domain in the From: header.

A message passes DMARC when either of these holds:

  • SPF passes and the envelope sender domain aligns with the From: domain, or
  • DKIM passes and the signature’s d= domain aligns with the From: domain.

One is enough — which is why DKIM alone keeps forwarded mail passing after SPF has broken.

Now the earlier attack falls apart. The attacker’s throwaway domain still passes SPF, but that domain doesn’t align with the From: header, so it contributes nothing to DMARC. With no aligned pass, DMARC fails, and your published policy decides what the receiver does about it.

Alignment comes in two modes, set per mechanism with aspf and adkim:

  • Relaxed (r, the default): the organizational domains must match. mail.example.com aligns with example.com.
  • Strict (s): the domains must match exactly. mail.example.com does not align with example.com.

Relaxed is the right default for nearly everyone. Strict is worth choosing deliberately: it rules out a whole class of subdomain-based abuse, but it fails any mail where the authenticated domain is a subdomain of the one in the From: header — which is exactly the shape most SaaS senders produce.

Where the organizational domain comes from changed. RFC 9989 determines it with a DNS tree walk — querying successively shorter names for a policy record — instead of consulting the Public Suffix List, as RFC 7489 did. For an ordinary company.com the answer is the same either way, so most domains notice nothing. It matters at the edges: multi-label suffixes, and organizations publishing policy at several levels of their namespace.

The policy itself is a TXT record at _dmarc.yourdomain.com:

v=DMARC1; p=reject; sp=reject; np=reject; adkim=r; aspf=r;
  rua=mailto:dmarc-reports@yourdomain.com
TagWhat it does
pWhat to do with unaligned mail: none, quarantine, or reject.
spPolicy for existing subdomains. Falls back to p.
npPolicy for subdomains that don’t exist in DNS. Falls back to sp, then p.
adkim / aspfAlignment mode, r or s. Default r.
tTest mode. t=y asks receivers not to apply the published policy directly; one step softer is the expected result.
foWhich failures generate a report: 0, 1, d, s.
ruaWhere to send aggregate reports.
rufWhere to send per-message failure reports.

np is the one most worth adding deliberately. Attackers love inventing subdomains you never created — billing.yourcompany.com has no DNS record, so nothing about your real mail setup constrains it. np=reject shuts that down without touching the subdomains you do use.

And t is the tag people misread. t=y does not mean “ignore my policy.” It asks receivers not to apply the published policy directly. RFC 9989 expects one step less severe — reject becomes quarantine, and quarantine becomes none — but final handling is still the receiver’s choice. It is a rollout brake, so a p=reject record with t=y is asking for quarantine until the tag is removed.

If you learned DMARC before 2026, check your record against the current spec. DMARC was revised in May 2026 and published as RFC 9989 (protocol), RFC 9990 (aggregate reports), and RFC 9991 (failure reports), together obsoleting RFC 7489 and promoting DMARC to a Standards Track protocol. The revision removed the pct, rf, and ri tagst replaces what pct was used for — and made p recommended rather than required. If p, sp, or np is invalid or p is absent, a receiver treats the record as p=none only when it has a valid rua; otherwise it does no DMARC processing. Existing v=DMARC1 records keep working: receivers ignore the removed tags rather than rejecting the record. Clean them up on your next DNS edit.

Putting it together: how one message gets judged

For a single incoming message, a receiver runs roughly this:

  1. Read the From: header domain. Everything else is measured against it.
  2. Evaluate SPF on the envelope sender’s domain against the connecting IP. Record pass/fail and which domain was checked.
  3. Verify any DKIM signatures. Record pass/fail and each signature’s d=.
  4. Test alignment. Does the SPF-checked domain match the From: domain? Does a passing d= match it?
  5. Any aligned pass → DMARC passes. The domain use is authenticated; normal delivery and filtering rules still apply.
  6. No aligned pass → DMARC fails. Look up the sender’s requested p (or sp/np) policy. With t=y, one step softer is expected; the receiver still makes the final handling decision.

Step 4 is where the surprises live, and it explains the result that sends people looking for a bug: spf=pass together with dmarc=fail is not a contradiction. It’s the normal signature of mail sent through a provider that never got configured to align — the envelope sender is the provider’s bounce domain, SPF passes on that domain, and nothing ties it to your From: header. Fix it by getting DKIM signing with d=yourdomain.com set up at that provider, which is what the aligned half of the check is looking for.

Reading your own setup

Two directions are worth checking, and they need different evidence.

Outbound — what you publish. All three records are public DNS, so anyone can read them, including you. Run your domain through the SPF and DMARC checker: it expands the SPF tree against the ten-lookup budget, reads the DMARC record tag by tag, verifies that external report addresses have authorized you to send to them, and probes common DKIM selectors. For the raw records with nothing interpreted, the DNS lookup tool queries TXT at yourdomain.com and _dmarc.yourdomain.com directly.

Inbound — what actually happened to one message. A published record tells you the rule; it doesn’t tell you the verdict on a specific email. That’s in the headers, where the receiving server writes down what it found:

Authentication-Results: mx.example.net;
  spf=pass smtp.mailfrom=bounces.provider.com;
  dkim=pass header.d=yourcompany.com;
  dmarc=pass header.from=yourcompany.com

Read that closely and the whole model is visible in one place: SPF passed on the provider’s domain, DKIM passed on yours, and because the DKIM d= aligns with header.from, DMARC passes. Save any message as its raw source and drop the headers into the email header analyzer to get this parsed out — it runs entirely in your browser, so the message never leaves your machine. For the full walkthrough, including the Received chain and the marks of a forged sender, see how to read email headers.

The short version

  • SPF answers “may this IP send for this domain?” — checked against the envelope sender, invisible to recipients, capped at ten DNS lookups, broken by forwarding.
  • DKIM answers “was this message signed by a key this domain published?” — travels with the message, survives forwarding, broken by modification, and its selectors can’t be enumerated.
  • DMARC answers “does an SPF or DKIM pass belong to the domain the reader actually sees?” — that’s alignment, and it’s the only one of the three that protects the From: header. It also tells receivers what to do on failure, and sends you reports.

You need all three, and you need them aligned. SPF and DKIM without DMARC authenticate addresses nobody looks at; DMARC without a passing, aligned SPF or DKIM just rejects your own mail.

Once the pieces make sense, the next question is operational: how do you get from a policy that does nothing to p=reject without blackholing your own invoices along the way? That’s a rollout problem rather than a protocol one, and moving from p=none to p=reject is the staged answer to it.