Home › Networking › Messaging & email protocols
algorithm low

SPF, DKIM and DMARC sender authentication

Problem Anyone can write any From address in a mail message, so a receiving server cannot tell which messages a domain really sent. Domain owners must publish who may send for them and what to do with the rest.

SPF authorizes sending IPs, DKIM signs the message, DMARC requires either to match the visible From domain and says what to do on failure.

SPF at most 10 DNS-causing terms; DKIM O(message bytes) plus one signature check; DMARC one TXT lookup
emailspfdkimdmarcdnsanti-spoofing
Overview

The From line of an email is free text, so any server can claim any domain. Three DNS records answer "did this domain send it?": SPF lists IPs allowed to send for the envelope sender, DKIM signs headers and body with a key published under the domain, and DMARC demands that a passing SPF or DKIM domain align with the From domain. Alignment matters: SPF and DKIM alone also pass for an attacker's own domain.

Key facts

Use when

  • Web apps send transactional mail (resets, receipts).
  • Newsletter and CRM platforms send for customer domains with CNAME-delegated DKIM keys.
  • Banks and brands fight phishing; BIMI logos need DMARC enforcement.

Avoid when

  • You need proof a person wrote the content: DKIM vouches for a domain.
  • A relay rewrites messages without re-signing: failures at p=reject.

Deep dive

How it works

In: client IP, HELO, MAIL FROM, the message · Out: spf, dkim, dmarc results, policy to apply · State: none; DNS answers cached by TTL · Cost: at most 10 SPF term lookups, one key lookup and hash pass per DKIM signature, 1-2 DMARC lookups

  1. SPF record. Query TXT of the MAIL FROM domain (RFC 7208 §2.4; HELO may go first). Keep records starting with exactly v=spf1: none gives none, two give permerror.
  2. SPF terms. Left to right: ip4, ip6, a, mx, include, all. The first match returns its qualifier: + pass (default), - fail, ~ softfail, ? neutral. No match: neutral (or the redirect= result). include uses only the inner result; a nested -all is just "no match".
  3. SPF limits. include, a, mx, ptr, exists and redirect count towards 10; an mx queries at most 10 addresses; over 2 void lookups (empty answers) is permerror (§4.6.4, SHOULD).
  4. DKIM sign. Canonicalize and hash the body (bh=, cut to l= bytes), hash the h= headers plus the DKIM-Signature field with empty b=, sign that hash (RFC 6376 §3.7).
  5. DKIM verify. Query TXT s._domainkey.d, recompute both hashes, check b= against p=. Empty p= means revoked.
  6. DMARC lookup. One From header only. Query TXT _dmarc.<From domain>; if absent, find the organizational domain by DNS tree walk (RFC 9989 §4.10; RFC 7489 used the Public Suffix List).
  7. Alignment. Identifiers: MAIL FROM domain (SPF), d= (DKIM). Relaxed (default): same organizational domain; strict (aspf=s, adkim=s): identical. Pass if SPF passed and aligns, or any DKIM signature passed and aligns.
  8. Policy. On failure apply p= (sp= subdomains, np= non-existent ones); t=y asks for one level softer. Receivers send aggregate reports to rua=.

DKIM-Signature tags: v=1, a= (rsa-sha256, ed25519-sha256), d= domain, s= selector, h= signed headers, bh= body hash, b= signature, c= canonicalization (default simple/simple; relaxed unfolds, lowercases names, collapses blanks), l= body length (§8.2: lets attackers append). Key record: v=DKIM1; k=rsa; p=<base64>; an Ed25519 key is 44 base64 characters (RFC 8463).

Code

use std::{collections::HashMap, net::Ipv4Addr};
// ---- DKIM relaxed canonicalization (RFC 6376 3.4.2 and 3.4.4) ----
fn squeeze(s: &str) -> String { // runs of space/tab become one space
    let mut o = String::new();
    for c in s.chars() {
        let sp = c == ' ' || c == '\t';
        if !(sp && o.ends_with(' ')) { o.push(if sp { ' ' } else { c }); }
    }
    o
}
pub fn relaxed_header(field: &str) -> String { // one raw field
    let (name, value) = field.split_once(':').unwrap();
    let value = squeeze(&value.replace("\r\n", "")); // unfold, then collapse WSP
    format!("{}:{}\r\n", name.trim_matches([' ', '\t']).to_ascii_lowercase(), value.trim_matches(' '))
}
pub fn relaxed_body(body: &str) -> String {
    let mut lines: Vec<String> = body.split("\r\n").map(|l| squeeze(l).trim_end().to_string()).collect();
    while lines.last().map_or(false, |l| l.is_empty()) { lines.pop(); } // trailing empty lines
    lines.iter().map(|l| format!("{l}\r\n")).collect() // empty body stays empty; missing CRLF added
}
// ---- SPF check_host() over an in-memory DNS (RFC 7208), IPv4 only ----
#[derive(Debug, PartialEq, Clone, Copy)] pub enum R { None, Neutral, Pass, Fail, SoftFail, PermError }
pub struct Dns(pub HashMap<String, Vec<String>>); // keys "TXT example.com", "MX domain", "A host"
#[derive(Default)] pub struct Budget { lookups: u32, voids: u32 }
fn get(d: &Dns, key: String) -> Vec<String> { d.0.get(&key).cloned().unwrap_or_default() }
fn in_net(a: Ipv4Addr, b: Ipv4Addr, len: u32) -> bool { u64::from(u32::from(a) ^ u32::from(b)) >> (32 - len) == 0 }
pub fn check_host(d: &Dns, ip: Ipv4Addr, domain: &str, b: &mut Budget) -> R {
    let txt = get(d, format!("TXT {domain}"));
    let spf: Vec<&String> = txt.iter().filter(|r| r.split(' ').next() == Some("v=spf1")).collect();
    let rec = match spf.len() { 0 => return R::None, 1 => spf[0], _ => return R::PermError };
    for term in rec.split_whitespace().skip(1) {
        if term.starts_with("exp=") { continue; } // explanation modifier, no effect on the result
        let (res, m) = match term.as_bytes()[0] {
            b'+' => (R::Pass, &term[1..]), b'-' => (R::Fail, &term[1..]),
            b'~' => (R::SoftFail, &term[1..]), b'?' => (R::Neutral, &term[1..]), _ => (R::Pass, term) };
        let (mech, rest) = m.find([':', '/']).map_or((m, ""), |i| (&m[..i], &m[i..]));
        let rest = rest.strip_prefix(':').unwrap_or(rest);
        let (target, len) = rest.rsplit_once('/').map_or((rest, Ok(32)), |(t, l)| (t, l.parse::<u32>()));
        let Ok(len @ 0..=32) = len else { return R::PermError };
        let dom = if target.is_empty() { domain } else { target };
        b.lookups += matches!(mech, "a" | "mx" | "include") as u32; // each DNS-causing term counts
        if b.lookups > 10 { return R::PermError; }
        let hit = match mech {
            "all" => true,
            "ip6" => false, // IPv4 sender: never matches
            "ip4" => match target.parse() { Ok(n) => in_net(ip, n, len), Err(_) => return R::PermError },
            "a" | "mx" => {
                let hosts = if mech == "a" { vec![dom.to_string()] } else { get(d, format!("MX {dom}")) };
                if hosts.len() > 10 { return R::PermError; } // at most 10 MX names per term
                let addrs: Vec<Vec<String>> = hosts.iter().map(|h| get(d, format!("A {h}"))).collect();
                b.voids += (hosts.is_empty() || addrs.iter().any(|v| v.is_empty())) as u32; // void lookups
                if b.voids > 2 { return R::PermError; }
                addrs.concat().iter().filter_map(|s| s.parse().ok()).any(|n| in_net(ip, n, len))
            }
            "include" => match check_host(d, ip, dom, b) { // only the inner result counts, not its -all
                R::Pass => true, R::Fail | R::SoftFail | R::Neutral => false, _ => return R::PermError },
            _ => return R::PermError, // redirect=, ptr, exists, macros: not implemented
        };
        if hit { return res; }
    }
    R::Neutral // default result
}
// ---- DMARC alignment, RFC 7489 style (RFC 9989: DNS tree walk instead of the list) ----
const SUFFIXES: [&str; 3] = ["com", "co.uk", "github.io"]; // tiny stand-in for the Public Suffix List
pub fn org_domain(host: &str) -> String {
    let l = host.to_ascii_lowercase();
    let v: Vec<&str> = l.split('.').collect();
    // first hit = longest suffix; else the default rule: the last label is the suffix
    let i = (1..v.len()).find(|&i| SUFFIXES.contains(&v[i..].join(".").as_str())).unwrap_or(v.len().max(1) - 1);
    v[i.saturating_sub(1)..].join(".")
}
pub fn aligned(from: &str, auth: &str, strict: bool) -> bool {
    if strict { from.eq_ignore_ascii_case(auth) } else { org_domain(from) == org_domain(auth) }
}
#[cfg(test)]
mod tests {
    use super::*;
    fn spf(rows: &[(&str, &str)], ip: &str, dom: &str) -> R {
        let mut m: HashMap<String, Vec<String>> = HashMap::new();
        for (k, v) in rows { m.entry(k.to_string()).or_default().push(v.to_string()); }
        check_host(&Dns(m), ip.parse().unwrap(), dom, &mut Budget::default())
    }
    #[test]
    fn rfc_examples() {
        // RFC 6376 3.4.5 example 1
        assert_eq!(relaxed_header("A: X \r\n"), "a:X\r\n");
        assert_eq!(relaxed_header("B : Y \t\r\n\t Z  \r\n"), "b:Y Z\r\n");
        assert_eq!(relaxed_body(" C \r\nD \t E\r\n\r\n\r\n"), " C\r\nD E\r\n");
        assert_eq!(relaxed_body(""), ""); // 3.4.4: null input
        // RFC 7208 appendix A.1 zone
        let zone = [("TXT example.com", "v=spf1 mx mx:example.org -all"), ("MX example.com", "mail-a.example.com"),
            ("MX example.com", "mail-b.example.com"), ("MX example.org", "mail-c.example.org"),
            ("A mail-a.example.com", "192.0.2.129"), ("A mail-b.example.com", "192.0.2.130"),
            ("A mail-c.example.org", "192.0.2.140")];
        for (ip, want) in [("192.0.2.130", R::Pass), ("192.0.2.140", R::Pass), ("192.0.2.10", R::Fail)] {
            assert_eq!(spf(&zone, ip, "example.com"), want);
        }
        // 11th DNS term and 3rd void lookup: permerror
        let (ten, eleven) = (format!("v=spf1 {}-all", "a:h.x ".repeat(10)), format!("v=spf1 {}-all", "a:h.x ".repeat(11)));
        let rows = [("A h.x", "10.0.0.1"), ("TXT ten", &ten), ("TXT eleven", &eleven), ("TXT void", "v=spf1 a:n1 a:n2 a:n3 -all")];
        let r = |dom| spf(&rows, "1.2.3.4", dom);
        assert_eq!([r("ten"), r("eleven"), r("void")], [R::Fail, R::PermError, R::PermError]);
        // RFC 7208 5.2: inner -all is not final
        let inc = [("TXT example.net", "v=spf1 include:example.com include:example.org -all"),
            ("TXT example.com", "v=spf1 a -all"), ("TXT example.org", "v=spf1 ip4:192.0.2.140 -all")];
        assert_eq!((spf(&inc, "192.0.2.140", "example.net"), spf(&inc, "192.0.2.9", "example.net")), (R::Pass, R::Fail));
        // RFC 9989 4.4 Table 1; github.io is a suffix, so b.github.io and c.github.io differ
        let (n, a) = ("news.example.com", aligned);
        assert!(a(n, "foo.example.com", false) && a(n, n, true) && !a(n, "foo.example.net", false) && !a(n, "foo.example.com", true));
        assert!(a("shop.example.co.uk", "bounce.example.co.uk", false) && !a("b.github.io", "c.github.io", false));
    }
}

The asserts are RFC examples. Signature verification is left out: hash relaxed_body and the relaxed h= fields with SHA-256, then call RSA or Ed25519 from a crypto library. redirect=, ptr, exists and macros are not implemented. The three-entry suffix list stands in for the Public Suffix List or the RFC 9989 tree walk.

Complexity and constants

  • SPF: one TXT query, at most 10 term lookups, 10 address queries per mx; records under 512 octets (§3.4).
  • DKIM: one pass over body and signed headers per signature; DMARC: one TXT query, tree walk at most 8. A 2048-bit RSA key is order of 390 base64 characters (two TXT strings); Ed25519 fits in one.
  • Measured (Apple M4 Pro, Rust 1.99.1 release, in-memory DNS): relaxed_body 277 MB/s; relaxed_header 334 ns; check_host on the mx mx:example.org -all record 802 ns; aligned 311 ns.

Efficiency

  • Cost: 50 KB message, one RSA-2048 signature, M4 Pro: SHA-256 about 16 µs (3.05 GB/s), RSA verify about 10 µs (openssl speed, measured), my canonicalizer about 180 µs (computed from 277 MB/s). DNS dominates: key, SPF and DMARC records, each order of ms uncached.
  • Levers:
    • cache DNS by TTL, negative answers too — memory read, not a round trip (order of ms to µs) — every receiver
    • canonicalize straight into the hasher — my string version is 11x below SHA-256 speed (measured) — high volume
    • sign once per message, reuse bh= for all recipients — one hash, one signature — newsletters
    • Ed25519 signing — 62k vs 3k signatures/s per core, verify 25k vs about 100k/s (measured); dual-sign, not every verifier does — large senders
    • flatten include chains — fewer lookups, under 10 — many sending vendors
  • Naive → standard → state of the art:
    • uncached query per term, a signature per recipient · several body passes
    • cached DNS, single-pass canonicalize and hash, one signature per message · one hash pass, one verify
    • canonicalizer fused with the hash, dual signatures · bound by hash speed (order of 3 GB/s)

Implementation notes

  • Canonicalize the received bytes, not a re-parse. Sign From (required, §5.4), listed twice in h= so an added From breaks the signature.
  • DNS timeout is temperror (retry), a malformed record permerror.

Pitfalls

  • SPF authenticates the envelope sender, not From; only alignment ties them.
  • Stacked vendor includes pass 10 lookups silently, giving permerror; checkdmarc counts them.
  • Forwarding changes the IP, so SPF fails; p=reject domains must not rely on SPF alone (RFC 9989).
  • Mailing lists add footers, breaking bh=. RFC 9989: receivers must not reject solely on p=reject; absent other analysis, treat it as quarantine.
  • pct is gone in RFC 9989 (use t); RFC 7489 and 9989 receivers may find different organizational domains (App. C.3).

Variants and alternatives

ARC (RFC 8617)forwarders seal the results they saw. Experimental; an April 2026 draft proposes Historic.
DKIM2draft successor with per-hop signatures.
BIMIbrand logo; needs DMARC quarantine or reject at 100 % plus a mark certificate.

In production

  • Gmail and Yahoo, since February 2024: SPF or DKIM for all senders; above 5,000 messages a day to Gmail also DMARC (p=none suffices), aligned From, one-click unsubscribe, spam rate under 0.30 %.
  • Twilio SendGrid has customers add CNAME records for SPF and DKIM; Amazon SES Easy DKIM defaults to 2048-bit keys.

Implementations

namelangrepoinstallverdictlicencehealthas_ofnotes
mail-authRuststalwartlabs/mail-authcargo add mail-auth————DKIM, ARC, SPF, DMARC
RspamdC, Luarspamd/rspamdapt install rspamd————spam filter with all four checks; also signs
OpenDMARCCtrusteddomainproject/OpenDMARCapt install opendmarc————milter; release 1.4.2
OpenDKIMCtrusteddomainproject/OpenDKIMapt install opendkim————milter; last upstream commit 2018
dkimpyPythonlaunchpad.net/dkimpypip install dkimpy————DKIM, ARC; Ed25519 via PyNaCl
checkdmarcPythondomainaware/checkdmarcpip install checkdmarc————audits published records, counts lookups

Sources

  • Kitterman, Sender Policy Framework (SPF), RFC 7208, 2014. RFC
  • Crocker et al. (eds.), DKIM Signatures, RFC 6376, 2011. RFC
  • Kitterman, Cryptographic Algorithm and Key Usage Update to DKIM, RFC 8301, 2018. RFC
  • Levine, A New Cryptographic Signature Method for DKIM, RFC 8463, 2018. RFC
  • Herr, Levine (eds.), DMARC, RFC 9989, 2026. RFC
  • Andersen et al., The Authenticated Received Chain (ARC) Protocol, RFC 8617, 2019. RFC · Historic draft
  • Google, Email sender guidelines; Yahoo, Sender best practices; BIMI Group, Implementation guide

Used by

email delivery smtp · plannedOutbound SMTP: queues, MX lookup, retries, bounces, feedback loops and IP warm-up

Systems (9):

Alternatives and related