Syed Hasibur Rahman30 min read

The Login Link Landed in Spam: SPF, DKIM, DMARC, and the Year DMARC Became a Standard

If your product signs people in with a one-time code or a magic link, email is part of your authentication system, and three DNS records decide whether that email arrives: SPF says which hosts may use your envelope domain, DKIM signs the message with a key you publish, and DMARC says that one of the two must pass for the domain the reader actually sees. In May 2026 the IETF replaced the informational DMARC document with three Standards Track RFCs that drop the pct tag, add test mode, and replace the Public Suffix List with a DNS tree walk. This post explains each mechanism, the failure modes that bite in production (the ten-lookup limit, forwarding, mailing lists, abandoned includes), the bulk-sender rules at the big mailbox providers, and a rollout plan that ends at enforcement without losing mail on the way.

The bug report says “login is broken.” It is not. The sign-in page accepted the address, the server generated a code, the mail provider’s API returned 202 Accepted, and the log line reads delivered. The user is staring at an empty inbox. Twenty minutes later they find the message in the spam folder, after the code has expired. Or they never find it, because the receiving server answered with a temporary failure and the sending relay politely retried for the next four hours.

Nothing in the application is wrong. The code path that issues the token is tested, the template renders, the HTTP call succeeds. What failed is a part of the system most teams never put in their architecture diagram: the question every receiving mail server asks before it decides where a message goes, which is “can I believe this message came from who it says it came from?” The answer to that question lives in three DNS records most developers set up once, by pasting what a provider’s onboarding wizard told them, and never read again.

If your product uses email for one-time codes, magic links, password resets, or invoices, those records are part of your authentication system. They have their own specifications, their own sharp edges, and, as of May 2026, a significant revision: DMARC, the policy layer that ties the other two together, left the Informational track after eleven years and became three Standards Track RFCs1. This post goes through all of it at the level of the specs: what each mechanism checks, why the part called alignment is the only part that matters to the reader, what changed in the new DMARC, what the large mailbox providers now require, and how to get to an enforcing policy without losing mail along the way.


1. One message, four domain names

Most confusion about email authentication comes from not noticing that a single email carries several different domain names, and that each mechanism checks a different one.

When a sending server connects to a receiving server, the SMTP conversation contains two identities before any message content is sent:

  • HELO/EHLO: the name the sending host gives for itself.
  • MAIL FROM: the envelope sender, also called the return path or bounce address. This is where delivery failures go. Users never see it.

Then the message itself is transmitted, and it contains headers, including:

  • From: (formally the RFC 5322.From header): the address the reader’s mail client displays. This is the only identity a human ever evaluates.
  • DKIM-Signature: d=: if the message is signed, the domain that claims responsibility for it.

An email service provider sending on your behalf will typically use its own domain for MAIL FROM (so it can process your bounces), put your domain in From:, and sign with either its domain or yours. All of that is legitimate. It also means that “SPF passed” and “DKIM passed” can both be true of a message whose From: header is a domain neither check looked at. That gap is the whole reason DMARC exists, and we will come back to it. First, the two underlying mechanisms.


2. SPF: which hosts may use this envelope domain

Sender Policy Framework is specified in RFC 7208 (Kitterman, April 2014), a Proposed Standard that obsoleted the experimental RFC 44082. An SPF record is a TXT record at the domain being checked, beginning v=spf1, listing the hosts allowed to send mail with that domain as an identity:

example.com.  TXT  "v=spf1 ip4:192.0.2.0/24 include:_spf.mailer.example mx -all"

The receiver evaluates the terms left to right against the connecting IP address. ip4 and ip6 match address ranges directly; a and mx match the addresses of the domain’s A or MX records; include evaluates another domain’s SPF record and matches if that one passes; all matches everything and conventionally ends the record. Each mechanism takes a qualifier: + pass, - fail, ~ softfail, ? neutral, and, as Section 4.6.2 puts it, “The qualifier is optional and defaults to “+”.”2

The identities SPF checks are MAIL FROM and HELO. Section 2.4 says verifiers “MUST check the “MAIL FROM” identity” where a HELO check has not already given a definitive result2. SPF never looks at the From: header. That is a design decision, and the specification explains why checking other identities is not recommended without explicit approval: mailing lists and other intermediaries routinely rewrite them.

The ten-lookup limit

Every include, a, mx, ptr, exists, and redirect causes DNS queries on the receiver’s side, and an attacker who controls a record can make a receiver do a lot of work. RFC 7208 caps it:

“SPF implementations MUST limit the total number of those terms to 10 during SPF evaluation.”2

Exceeding the limit, the same section says, means the implementation “MUST return “permerror.”” The terms counted are the ones that cause queries, and the count runs across the whole evaluation, including the records pulled in by every nested include. ip4, ip6, and all are free. There is a second, quieter limit: implementations “SHOULD limit “void lookups” to two”, where a void lookup is a query that returns no answer or NXDOMAIN, and exceeding that limit also produces a permerror2.

This is how SPF breaks in practice. Nobody writes an eleven-lookup record on purpose. A company adds its transactional provider, then a marketing platform, then a CRM, then a helpdesk tool, each with an onboarding page that says “add include: this to your SPF record.” Each vendor’s record includes two or three of its own. One morning the total crosses ten, and every receiver that enforces the limit starts returning permerror for every message, from every sender, including the ones that were fine yesterday. A permerror is not a fail, but it is not a pass either, and for DMARC purposes an SPF result that is not a pass contributes nothing.

Two more details from the spec are worth knowing. The ptr mechanism, which does a reverse lookup of the connecting IP, is slow and unreliable, and Section 5.5 is blunt about it: “This mechanism SHOULD NOT be published.”2 And include: of a domain that has no SPF record at all is not a harmless no-op. The table in Section 5.2 maps a recursive result of “none” to permerror, and Section 6.1 says that for redirect=, “if no SPF record is found, or if the is malformed, the result is a “permerror” rather than “none”.”2

Auditing the budget

The check is mechanical enough that it belongs in CI next to your DNS-as-code, not in someone’s memory. This script walks a domain’s SPF record, recursing into every include and redirect, counts the terms that cost a lookup, and flags targets that no longer publish a record:

"""Count the DNS-querying terms an SPF evaluation of `domain` will spend.

RFC 7208 section 4.6.4: include, a, mx, ptr, exists and the redirect
modifier each count toward a limit of 10; all, ip4 and ip6 do not.
"""
import re
import sys

LOOKUP_TERMS = {"include", "a", "mx", "ptr", "exists", "redirect"}


def txt_records(name):
    import dns.resolver  # pip install dnspython
    try:
        answers = dns.resolver.resolve(name, "TXT")
    except (dns.resolver.NXDOMAIN, dns.resolver.NoAnswer):
        return []
    return [b"".join(r.strings).decode() for r in answers]


def spf_record(domain, lookup=txt_records):
    recs = [t for t in lookup(domain) if t.lower().startswith("v=spf1 ")
            or t.lower() == "v=spf1"]
    if len(recs) != 1:
        return None  # zero records = none; more than one = permerror
    return recs[0]


def is_lookup(term):
    name = re.split(r"[:/=]", term.lstrip("+-~?").lower(), maxsplit=1)[0]
    return name in LOOKUP_TERMS


def budget(domain, lookup=txt_records, depth=0):
    """Return (lookup_count, broken_targets, tree_lines)."""
    rec = spf_record(domain, lookup)
    if rec is None:
        # An include: or redirect= target without exactly one SPF record
        # is a permerror (RFC 7208 sections 5.2 and 6.1), and a target
        # that no longer exists is how SPF takeovers start.
        return 0, 1, ["  " * depth + f"{domain}: NO SPF RECORD"]
    if depth > 10:
        return 11, 0, ["  " * depth + "...loop?"]
    count, broken, lines = 0, 0, ["  " * depth + f"{domain}: {rec}"]
    for term in rec.split()[1:]:
        if not is_lookup(term):
            continue
        count += 1
        t = term.lstrip("+-~?").lower()
        target = None
        if t.startswith("include:"):
            target = term.split(":", 1)[1]
        elif t.startswith("redirect="):
            target = term.split("=", 1)[1]
        if target:
            c, b, sub = budget(target, lookup, depth + 1)
            count, broken, lines = count + c, broken + b, lines + sub
    return count, broken, lines


if __name__ == "__main__":
    n, b, tree = budget(sys.argv[1])
    print("\n".join(tree))
    print(f"\nDNS-querying terms: {n}/10   targets without SPF: {b}")
    if n > 10 or b:
        print("PERMERROR: receivers will treat this policy as broken")
        sys.exit(1)

It is deliberately conservative. It does not expand macros, and it does not count the per-MX address lookups that Section 4.6.4 limits separately. It does catch the two failures that actually happen: creeping past ten, and an include: that points at a vendor you stopped paying years ago. The second one, as Section 9 of this post shows, is not only a reliability problem.

What SPF cannot survive

SPF authorises a host for an envelope domain. When a message is forwarded, by a university alias, a .forward file, or a “send a copy to my Gmail” rule, the forwarding server connects to the final receiver with its own IP address, still carrying the original MAIL FROM. RFC 7208 Section 10.3 describes exactly this: the check is made against the mediator’s address and, if MAIL FROM is unchanged, the evaluation “will fail unless mitigations such as those described in” Appendix D are used2. The main mitigation in Appendix D.2 is for the forwarder to rewrite MAIL FROM into its own domain, which fixes SPF and, as we will see, does nothing for DMARC, because the rewritten domain is no longer yours.

So SPF is useful and brittle. It is a statement about network paths, and network paths are the first thing forwarding changes.


3. DKIM: a signature that travels with the message

DomainKeys Identified Mail is RFC 6376 (Crocker, Hansen, and Kucherawy, editors, September 2011), an Internet Standard, STD 763. Where SPF is about paths, DKIM is about content. The signer hashes the body and a chosen set of header fields, signs the result with a private key, and adds a DKIM-Signature header. The receiver fetches the public key from DNS and verifies.

The abstract states what the signature means with unusual care: DKIM “permits a person, role, or organization that owns the signing domain to claim some responsibility for a message”3. It is a claim of responsibility by the domain in d=, nothing more. It says nothing about whether the content is honest, or whether d= has anything to do with the From: address.

A signature header looks like this (wrapped for readability):

DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=example.com;
    s=s2026a; t=1760594400;
    h=from:to:subject:date:message-id:from:subject;
    bh=2jUSOH9NhtVGCQWNr9BrIAPreKQjO6Sn7XIkfJVOzv8=;
    b=AuUoFEfDxTDkHlLXSZEpZj79LICEps6eda7W3deTVFOk4yAUoqOB...

The required tags, per Section 3.5, are v, a, b, bh, d, h, and s. The public key lives at a name built from the selector and the domain. Section 3.6.2.1: “All DKIM keys are stored in a subdomain named “_domainkey”.”3 So the key for the signature above is a TXT record at s2026a._domainkey.example.com:

s2026a._domainkey.example.com.  TXT  "v=DKIM1; k=rsa; p=MIIBIjANBgkqhkiG9w0BAQEFAAOC..."

Selectors are what make key rotation possible. You publish a new selector, switch the signer to it, and leave the old public key in DNS until messages signed with it have stopped being verified. Then remove it. A selector naming scheme with a date in it (s2026a) makes the rotation history legible to whoever inherits the zone.

What the signer must and should cover

Section 5.4 makes one header mandatory: “The From header field MUST be signed (that is, included in the “h=” tag of the resulting DKIM-Signature header field).”3 Beyond that, the signer chooses. Two choices in the spec are worth understanding precisely.

The first is over-signing. Notice that from and subject appear twice in the h= list above. RFC 6376 Section 5.4.2 allows the signer to list a header name more times than the header actually occurs, and the effect is that an intermediary who adds a second From: or Subject: header after signing invalidates the signature. Section 8.15 discusses this attack class, noting that “DKIM can aid in detecting addition of specific fields in transit.”3 Without over-signing, a message with two From: headers can verify, and different mail clients disagree about which one to display.

The second is the body length tag, l=, which tells the verifier to hash only the first N octets of the body. It exists so that mailing lists can append footers without breaking signatures. Section 8.2 explains why that convenience is dangerous: “Use of the “l=” tag might allow display of fraudulent content without appropriate warning to end users.”3 Anyone can append content after the signed length, including an HTML block that visually replaces everything above it. Do not set l=. If your provider sets it, ask them to stop.

Keys and algorithms

RFC 8301 (Kitterman, January 2018) updated DKIM’s cryptography. It removed SHA-1 entirely: “rsa-sha1 MUST NOT be used for signing or verifying.” Signers “MUST use RSA keys of at least 1024 bits for all keys” and “SHOULD use RSA keys of at least 2048 bits”, while verifiers “MUST be able to validate signatures with keys ranging from 1024 bits to 4096 bits”4. Gmail’s sender guidelines enforce the floor: “Sending to personal Gmail accounts requires a DKIM key of 1024 bits or longer.”5 Use 2048. One practical snag is that a 2048-bit RSA public key is longer than the 255-octet limit on a single DNS character-string, so the TXT record has to be split into multiple quoted strings, which many DNS control panels handle badly. Check the published record with dig, not the control panel’s preview.

RFC 8463 (Levine, September 2018) added ed25519-sha256, with the key published as k=ed255196. The keys are short and the signatures fast. The specification says “Signers SHOULD implement and verifiers MUST implement the Ed25519-SHA256 algorithm,” and, for the transition, “signers can add multiple signatures that use old and new signing algorithms.”6 Dual-signing with RSA and Ed25519 is the safe way to adopt it, since a message with one valid signature from the right domain is enough for DMARC.

Because the signature is carried in the message, DKIM survives plain forwarding, which SPF does not. It does not survive modification. A mailing list that adds [list-name] to the subject or a footer to the body breaks it, and so does any gateway that rewrites links or re-encodes MIME parts.


4. DMARC: alignment is the point

Now the gap from Section 1. A phisher can send from their own server with MAIL FROM set to a domain they own, with a perfectly valid SPF record, sign with their own domain’s DKIM key, and put From: Your Bank <[email protected]> in the header. SPF passes. DKIM passes. The reader sees your bank’s name.

DMARC closes that gap with one concept: identifier alignment. The original specification, RFC 7489 (Kucherawy and Zwicky, editors, March 2015), was published as an Informational document through the Independent Submission stream7. Its central rule, in Section 4.2: “A message satisfies the DMARC checks if at least one of the supported authentication mechanisms” produces a pass, and that pass is based on an identifier that is in alignment with the From: domain7.

In other words: SPF must pass for a MAIL FROM domain that matches From:, or DKIM must pass for a d= domain that matches From:. Either one is enough. A pass for some other domain contributes nothing.

“Matches” comes in two strengths. Under relaxed alignment, the default for both aspf and adkim, the two domains need only share an Organizational Domain, so a message signed d=mail.example.com aligns with From: [email protected]. Under strict alignment they must be identical7. The current specification keeps relaxed as the default for both1.

The policy is a TXT record at _dmarc under the From: domain:

_dmarc.example.com.  TXT  "v=DMARC1; p=quarantine; rua=mailto:[email protected]"

p=none asks receivers to take no action and just report; p=quarantine asks them to treat failures as suspicious, usually meaning the spam folder; p=reject asks them to refuse failing mail, ideally during the SMTP transaction. rua names where aggregate reports go7.

Notice what this means for the opening scenario. If your login mail goes out through a provider that uses its own bounce domain and signs with its own d=, both SPF and DKIM can pass while DMARC fails, because neither pass is aligned with your From: domain. With p=none that changes nothing visible. The moment someone tightens the policy, or a receiver weighs alignment into its spam scoring, your codes start going missing. The fix is almost always on the DKIM side: have the provider sign with a key under your domain (most offer this as “custom domain authentication,” usually via a CNAME at selector._domainkey), and optionally give it a custom bounce subdomain of yours so SPF aligns too.

DKIM alignment is the one to rely on, because it is the one that survives forwarding. SPF alignment is a bonus.


5. RFC 9989: what changed when DMARC became a standard

In May 2026 the RFC Editor published the long-running “DMARCbis” work as three documents, all Proposed Standards, all obsoleting RFC 7489:

  • RFC 9989, Domain-Based Message Authentication, Reporting, and Conformance (DMARC), edited by T. Herr and J. Levine: the protocol itself. It also obsoletes RFC 9091, the experimental Public Suffix Domain extension1.
  • RFC 9990, DMARC Aggregate Reporting, edited by A. Brotman8.
  • RFC 9991, DMARC Failure Reporting, edited by S. Jones and A. Vesely9.

Appendix C of RFC 9989 lists the changes from RFC 7489. The headline is procedural, “This document, by contrast, is an Internet Standards Track document”, but several changes affect the records you publish.

pct is gone; t replaces it

RFC 7489’s pct tag let a domain owner apply its policy to a percentage of failing mail, so p=reject; pct=10 meant “reject a tenth of it.” Appendix A.6 of RFC 9989 explains the removal: operators found that values other than 0 and 100 were usually not applied accurately, and it “didn’t make sense to support a tag named “pct” that had only two valid values.”1

The replacement is a test-mode flag, defined in Section 4.7 as “DMARC policy test mode (plain-text; OPTIONAL; default is “n”).” With t=y, the receiver is asked not to apply the published policy as written; the domain owner expects failing mail to be handled one level below it, so p=quarantine; t=y behaves like none and p=reject; t=y behaves like quarantine1. It changes nothing about reporting. If you have pct= in a record today, note that RFC 9989 lists it as historic and says “unknown tags MUST be ignored”1, so a receiver implementing the new specification can read p=reject; pct=10 as plain p=reject. Remove it deliberately before someone else’s parser does it for you.

The Public Suffix List is replaced by a DNS tree walk

Relaxed alignment needs to know the Organizational Domain: that mail.example.co.uk belongs to example.co.uk and not to co.uk. RFC 7489 answered that with the Public Suffix List, a file maintained outside the IETF that every receiver had to keep current. RFC 9989 Appendix C.3 says reliance on it “has been replaced by a technique called a “DNS Tree Walk”.”1

The walk, in Section 4.10, queries for a DMARC record at _dmarc under the Author Domain and then under successively shorter parent names, removing one label at a time. It stops early when it finds a record carrying a psd=y or psd=n tag, and otherwise continues until no labels remain. A domain with more than seven labels is shortened to seven in a single step, which the spec states as “Author Domains with more than eight labels do not result in more than eight DNS queries.”1 The new psd tag lets the operators of public suffixes declare themselves (psd=y) and lets an organization declare that it is its own Organizational Domain (psd=n). Most domain owners never need to publish psd at all. The default, u, means “work it out from the walk.”

The change you can make use of is np, a policy for non-existent subdomains. Section 4.7 defines it as the Domain Owner’s policy “for non-existent subdomains of the given Organizational Domain”, falling back to sp and then p when absent1. Attackers like inventing subdomains (secure-login.example.com) because they inherit no explicit record. np=reject lets you refuse mail from names that do not exist in your zone at all, even while your real policy is still none or quarantine.

The tags rf and ri (failure-report format and requested report interval) were removed as well, per Appendix C.5.21.

The mailing-list problem, stated as a requirement

The most consequential text in RFC 9989 is not a new tag. It is the specification finally writing down, in normative language, the interoperability damage p=reject does. Mailing lists modify messages, which breaks DKIM, and re-send them from their own servers, which breaks SPF alignment, so a list post from a p=reject domain fails DMARC at every subscriber. Section 7.4 states:

“It is therefore critical that domains that host users who might post messages to mailing lists SHOULD NOT publish Domain Owner Assessment Policies of “p=reject”.”1

Domains that publish p=reject anyway “SHOULD implement policies that their users not post to Internet mailing lists”, and the recommended path to p=reject is to publish p=none “for at least a month, followed by publishing “p=quarantine” for an equally long period of time”, comparing results before going further1. On the receiving side, Section 8 says Mail Receivers “MUST NOT reject messages solely on the basis of a “p=reject” policy for the Author Domain”, and Section 7.4 goes further: “In the absence of other knowledge and analysis, Mail Receivers MUST treat such failing mail as if the policy were “p=quarantine” rather than “p=reject”.”1

Read those two sentences together and the design becomes clear. The standard now treats p=reject as appropriate for domains that send machine-generated mail, like transactional and notification streams, and as a hazard for domains where humans send mail to communities. That maps neatly onto a practice worth adopting anyway: send your application’s mail from a dedicated subdomain (notify.example.com, mail.example.com) with its own DMARC record at p=reject, and keep the domain your staff use for correspondence at a policy that does not break their mailing lists.

Reports, standardized

RFC 9990 standardizes the aggregate report format. Each report is an XML document whose root feedback element lives in the namespace urn:ietf:params:xml:ns:dmarc-2.0, delivered as an email attachment and gzip-compressed (the spec says it SHOULD be). Reports are “daily (or more frequent)”, and “Typically, the reporting period will encompass a single UTC day, beginning at 0000UTC.”8 Each record covers one source IP and result combination, with a policy_evaluated block (the aligned verdicts DMARC used) and an auth_results block (the raw SPF and DKIM results, without DMARC interpretation). The policy_published block now records which discovery method the reporter used, psl or treewalk, which is useful during the transition period, when your reports will come from receivers on both sides.

If rua points at a domain other than your own, for example a reporting service, RFC 9990 Section 4 requires the receiver to check for an authorizing record at <your-domain>._report._dmarc.<their-host> and to ignore the destination if it is absent8. Reputable services publish this for you. If your reports are silently not arriving, that record is the first thing to check.

RFC 9991 covers failure reports, which are per-message and can contain addresses and message content. It recommends redaction and notes that many large providers limit or disable them, preferring aggregate reports9. Plan on aggregate reports as your source of truth.


6. The mailbox providers stopped asking

For most of DMARC’s life, authentication was a good idea that providers rewarded. In October 2023 Gmail announced that it would become a requirement. Neil Kumaran’s announcement said that “By February 2024, Gmail will start to require that bulk senders”, defined as those sending “more than 5,000 messages to Gmail addresses in one day”, authenticate their mail, offer unsubscription “in one click” and process it within two days, and stay under a spam-rate threshold. The same post quoted Yahoo’s support for the changes10.

The current versions of the rules:

Gmail, for all senders: “Set up SPF or DKIM email authentication for your sending domains,” “Use a TLS connection for transmitting email,” and “Keep spam rates reported in Postmaster Tools below 0.3%.” For senders of 5,000 or more messages a day, additionally: “Set up DMARC email authentication for your sending domain,” the From: domain “must be aligned with either the SPF domain or the DKIM domain,” and marketing and subscribed messages must support one-click unsubscribe. The guidance on spam rate is stricter than the requirement: “Keep spam rates reported in Postmaster Tools below 0.10% and avoid ever reaching a spam rate of 0.30% or higher.”5

Yahoo, for bulk senders: “Implement both SPF & DKIM,” “Publish a valid DMARC policy with at least p=none - DMARC must pass,” align the From: domain with either SPF or DKIM (“Relaxed alignment is acceptable”), support one-click unsubscribe, with “The Post (RFC 8058) method is highly recommended,” “Honor unsubscribes within 2 days,” and “Keep your spam rate below 0.3%.”11

Microsoft applies its rule to senders of “5,000 or more email messages to Microsoft consumer email services” from the same From: domain. They must publish SPF and DKIM, “Both SPF and DKIM checks must pass,” publish a DMARC record of at least p=none, and have at least one of SPF or DKIM align with the From: domain. Non-compliant mail is refused with “550 5.7.515 Access denied, sending domain does not meet the required authentication level.”12

Two things stand out. First, p=none satisfies the letter of all three. The providers require that you have a DMARC record and pass it, not that you enforce it. Second, the thresholds are per From: domain per day, and a startup’s login mail crosses 5,000 a day sooner than its founders expect. Treat the bulk-sender bar as the baseline for any domain that sends application mail, whatever its volume today. Yahoo and Microsoft ask for both SPF and DKIM; aligned DKIM plus a passing SPF for the envelope domain satisfies everyone.


7. One-click unsubscribe, briefly

Transactional mail (codes, resets, receipts) does not need an unsubscribe link, and you should not add one to a login code. Product newsletters and “what’s new” mail from the same platform do, and the mechanism the providers point to is RFC 8058 (Levine and Herkula, January 2017)13. It exists because anti-spam systems fetch URLs found in headers, and as its introduction notes, “there is no mechanical way for a sender to tell whether a request was made” by software or by a person. A plain GET unsubscribe link gets triggered by scanners. RFC 8058 makes the unsubscription a POST:

List-Unsubscribe: <https://notify.example.com/u/7Hk2pX9qLw>
List-Unsubscribe-Post: List-Unsubscribe=One-Click

The rules are short. “The List-Unsubscribe header field MUST contain one HTTPS URI.” “The List-Unsubscribe-Post header MUST contain the single key/value pair “List-Unsubscribe=One-Click”.” The mail client performs an HTTPS POST to that URI with the pair as the body. And the message “MUST have a valid DomainKeys Identified Mail (DKIM) signature” that covers at least those two headers13, which is one more reason your DKIM h= list deserves attention. Make the token in the URL opaque, single-purpose, and bound to the subscription, and make the endpoint idempotent.


8. Forwarding, mailing lists, and ARC

Section 5 described what p=reject does to mailing lists. The industry’s attempt at a repair is the Authenticated Received Chain, RFC 8617 (Andersen, Long, Blank, and Kucherawy, July 2019)14. An ARC-aware intermediary, before it modifies a message, records the authentication results it saw in an ARC-Authentication-Results header, signs the message as it leaves in an ARC-Message-Signature, and seals the chain with an ARC-Seal. The final receiver can then see that the message passed DMARC when it arrived at the list, even though it fails now.

RFC 8617 is Experimental, and it is careful about what it does not promise. Section 9.3: “ARC authenticates the identity of some email-handling actors. It does not make any assessment of their trustworthiness.” Appendix A.2: “ARC is not a trust framework.”14 A receiver uses ARC results only if it chooses to trust the specific intermediaries who sealed them. Large providers seal and evaluate ARC; your own inbound server may not. As a sender you cannot rely on ARC to rescue your mail, which is why the advice from Section 5 stands: keep p=reject on domains that do not send to lists.


9. The include you forgot is someone else’s mail server

On 26 February 2024, Guardio Labs published “SubdoMailing,” an analysis of a campaign that had taken over the mail authentication of more than 8,000 domains belonging to well-known brands, sending what the researchers described as “Millions of malicious emails sent daily”15. It used two techniques, and neither involved breaking any cryptography.

In the first, a brand’s subdomain had a CNAME pointing at a domain that had since been abandoned. The attacker registered the abandoned domain and thereby inherited what the subdomain resolved to, SPF record included. Guardio’s example was marthastewart.msn.com, a CNAME to a promotional domain that had lapsed. “No one re-claimed this domain name again for 21 years!” the researchers wrote, until it was registered in September 2022. The attacker’s SPF record behind it expanded to “a massive list of 17826 IPs”15.

In the second, a domain’s SPF record still carried an include: of a domain once used by an email or marketing vendor that had since expired. The attacker registered it and published an SPF record listing their own servers. Every receiver evaluating the victim’s SPF record now followed the include to the attacker’s record and found the attacker’s IPs authorized15.

Notice what DMARC does and does not do here. Mail with From: set to the brand’s domain, sent from an attacker IP that the brand’s own SPF record (via the hijacked include) authorizes, passes aligned SPF. A p=reject policy does not help, because the message passes. The only defenses are hygiene: remove includes for vendors you no longer use, remove CNAMEs to domains you do not control, and monitor. The SPF audit script in Section 2 fails the build when an include target disappears, which is the window between “vendor domain expired” and “attacker registered it.” The DMARC aggregate reports in the next section show you the IPs that are passing as you, which is the only way to notice afterward.


10. Reading your aggregate reports

Nobody reads raw DMARC XML. Commercial report processors are fine, and so is a small script, which has the advantage of keeping the data, a map of every server that sends as your domain, in-house. This one reads both the RFC 7489-era format and the namespaced RFC 9990 format, in plain, gzip, or zip form, and groups by source IP:

"""Summarise DMARC aggregate reports: who is sending as your domain?

Handles RFC 7489-era reports (no XML namespace) and RFC 9990 reports
(urn:ietf:params:xml:ns:dmarc-2.0). Reports arrive from strangers,
so parse with defusedxml, not the stdlib parser.
"""
import gzip
import sys
import zipfile
from collections import defaultdict

from defusedxml.ElementTree import fromstring  # pip install defusedxml


def load(path):
    with open(path, "rb") as f:
        data = f.read()
    if data[:2] == b"\x1f\x8b":            # .xml.gz
        data = gzip.decompress(data)
    elif data[:2] == b"PK":                # some reporters still send .zip
        with zipfile.ZipFile(path) as z:
            data = z.read(z.namelist()[0])
    root = fromstring(data)
    for el in root.iter():                 # drop namespaces: old and new alike
        el.tag = el.tag.rsplit("}", 1)[-1]
    return root


def text(el, path, default=""):
    found = el.find(path) if el is not None else None
    return found.text.strip() if found is not None and found.text else default


def summarise(paths):
    rows = defaultdict(lambda: {"count": 0, "aligned": 0,
                                "dkim": set(), "spf": set()})
    for path in paths:
        root = load(path)
        reporter = text(root, "report_metadata/org_name", "?")
        for rec in root.iter("record"):
            n = int(text(rec, "row/count", "0"))
            pe = rec.find("row/policy_evaluated")
            # policy_evaluated carries the *aligned* verdicts DMARC used
            ok = "pass" in (text(pe, "dkim"), text(pe, "spf"))
            key = (text(rec, "row/source_ip"),
                   text(rec, "identifiers/header_from"), reporter)
            r = rows[key]
            r["count"] += n
            r["aligned"] += n if ok else 0
            # auth_results carries the raw, unaligned results
            for d in rec.findall("auth_results/dkim"):
                r["dkim"].add(f'{text(d, "domain")}={text(d, "result")}')
            for s in rec.findall("auth_results/spf"):
                r["spf"].add(f'{text(s, "domain")}={text(s, "result")}')
    return rows


if __name__ == "__main__":
    rows = summarise(sys.argv[1:])
    for (ip, hfrom, rep), r in sorted(rows.items(),
                                      key=lambda kv: -kv[1]["count"]):
        flag = "" if r["aligned"] == r["count"] else "   <-- not aligned"
        print(f"{ip:<39} {hfrom:<20} {r['aligned']:>6}/{r['count']:<6} "
              f"{rep}  dkim[{','.join(sorted(r['dkim']))}] "
              f"spf[{','.join(sorted(r['spf']))}]{flag}")

Run against a week of reports, the output sorts itself into three groups. The first is your own infrastructure and providers, passing aligned: good. The second is your own providers, failing alignment. The tell is a row where auth_results shows spf[bounces.provider.example=pass] and no DKIM for your domain. That is the opening scenario, caught before enforcement, and the fix is custom-domain DKIM at that provider. The third is IPs you do not recognize, failing. Some are forwarders (these usually show a DKIM pass for your domain in auth_results with a broken signature, or an SPF fail from a university or ISP range). The rest are people sending as you. That group is the reason to move past p=none.

The defusedxml import is not decoration. DMARC reports are XML files that strangers email to an address you publish in DNS. Parse them as you would any other untrusted XML.


11. A rollout that ends at enforcement

Here is the sequence, for a product domain whose application sends login and notification mail through one or more providers.

  1. Separate the streams. Put application mail on its own subdomain (notify.example.com) with its own SPF, DKIM selectors, and DMARC record. Marketing mail goes on another. Staff mail stays on the apex. Each now has its own reputation and its own policy, and RFC 9989’s mailing-list guidance stops being a conflict.
  2. Get aligned DKIM at every provider. Use custom-domain signing, a 2048-bit RSA key (or RSA plus Ed25519), and a dated selector. Check that h= covers From, Subject, Date, To, Message-ID, and the List-Unsubscribe pair where present, with From and Subject over-signed. Confirm no l=.
  3. Fix SPF to pass, and keep it under ten. Run the budget check in CI. Delete every include you cannot name the owner of. End with -all on the application subdomain, where you know every sender; ~all is fine on the apex while you discover senders.
  4. Publish p=none with rua, and np=reject from day one on any domain where you control every hostname. Read reports weekly.
  5. After a month of clean reports, p=quarantine (optionally with t=y first if you want a week of none-equivalent handling under the new label). Leave it for at least another month, per RFC 9989 Section 7.4.
  6. p=reject on the application subdomain. For the apex, decide deliberately: if staff post to mailing lists, quarantine is where RFC 9989 says to stop.
  7. Remove pct= from every record, and make sure your DNS tooling does not reintroduce it.
  8. Keep reading reports. Enforcement is not the end state. A new vendor, a new marketing tool, or an expired include changes the picture without anyone touching the DMARC record.

Closing: email is part of your auth stack

The usual way teams treat this subject is as deliverability, a marketing concern, something the email provider handles. For any product that signs users in through their inbox, that framing is wrong. A one-time code that arrives in spam after it expires is a failed login. A From: domain that anyone can spoof is a phishing kit pre-loaded with your brand. A forgotten include: is a credential you handed to whoever next registers a lapsed domain. These are authentication failures, and they deserve the same engineering as the token endpoint: specified, tested in CI, monitored, and owned by someone.

The May 2026 standards make the job clearer than it has been. DMARC is now a Standards Track protocol with a test mode that means what it says, a discovery method that does not depend on a file outside the IETF, a policy for subdomains that do not exist, and an explicit statement of where p=reject belongs. The large receivers have spent two years requiring authentication from anyone sending at volume. What remains is mostly unglamorous: aligned DKIM at every provider, an SPF record someone has actually counted, and a report reader that runs every week.

The login link should land in the inbox. That is a property you can engineer, and since the providers now enforce it, you no longer get to treat it as luck.


References


  1. Herr, T., Ed., and Levine, J., Ed., “Domain-Based Message Authentication, Reporting, and Conformance (DMARC)”, IETF RFC 9989, May 2026. https://www.rfc-editor.org/rfc/rfc9989 ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎

  2. Kitterman, S., “Sender Policy Framework (SPF) for Authorizing Use of Domains in Email, Version 1”, IETF RFC 7208, April 2014. https://www.rfc-editor.org/rfc/rfc7208 ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎

  3. Crocker, D., Ed., Hansen, T., Ed., and Kucherawy, M., Ed., “DomainKeys Identified Mail (DKIM) Signatures”, IETF RFC 6376 (STD 76), September 2011. https://www.rfc-editor.org/rfc/rfc6376 ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎

  4. Kitterman, S., “Cryptographic Algorithm and Key Usage Update to DomainKeys Identified Mail (DKIM)”, IETF RFC 8301, January 2018. https://www.rfc-editor.org/rfc/rfc8301 ↩︎

  5. Google, “Email sender guidelines”, Gmail Help. https://support.google.com/a/answer/81126 ↩︎ ↩︎

  6. Levine, J., “A New Cryptographic Signature Method for DomainKeys Identified Mail (DKIM)”, IETF RFC 8463, September 2018. https://www.rfc-editor.org/rfc/rfc8463 ↩︎ ↩︎

  7. Kucherawy, M., Ed., and Zwicky, E., Ed., “Domain-based Message Authentication, Reporting, and Conformance (DMARC)”, RFC 7489 (Informational, Independent Submission), March 2015; obsoleted by RFCs 9989, 9990, and 9991. https://www.rfc-editor.org/rfc/rfc7489 ↩︎ ↩︎ ↩︎ ↩︎

  8. Brotman, A., Ed., “Domain-Based Message Authentication, Reporting, and Conformance (DMARC) Aggregate Reporting”, IETF RFC 9990, May 2026. https://www.rfc-editor.org/rfc/rfc9990 ↩︎ ↩︎ ↩︎

  9. Jones, S., Ed., and Vesely, A., Ed., “Domain-Based Message Authentication, Reporting, and Conformance (DMARC) Failure Reporting”, IETF RFC 9991, May 2026. https://datatracker.ietf.org/doc/rfc9991/ ↩︎ ↩︎

  10. Kumaran, N., “New Gmail protections for a safer, less spammy inbox”, Google Keyword blog, 3 October 2023. https://blog.google/products/gmail/gmail-security-authentication-spam-protection/ ↩︎

  11. Yahoo, “Sender Best Practices”, Yahoo Sender Hub. https://senders.yahooinc.com/best-practices/ ↩︎

  12. Microsoft, “Fix NDR error “550 5.7.515” in Outlook.com”, Microsoft Support. https://support.microsoft.com/en-us/outlook/fix-ndr-error-550-5-7-515-in-outlook-com ↩︎

  13. Levine, J. and Herkula, T., “Signaling One-Click Functionality for List Email Headers”, IETF RFC 8058, January 2017. https://www.rfc-editor.org/rfc/rfc8058 ↩︎ ↩︎

  14. Andersen, K., Long, B., Ed., Blank, S., Ed., and Kucherawy, M., Ed., “The Authenticated Received Chain (ARC) Protocol”, IETF RFC 8617 (Experimental), July 2019. https://www.rfc-editor.org/rfc/rfc8617 ↩︎ ↩︎

  15. Tal, N. and Zaytsev, O., "‘SubdoMailing’: Thousands of Hijacked Major-Brand Subdomains Found Bombarding Users With Millions of Malicious Emails", Guardio Labs, 26 February 2024. https://guard.io/labs/subdomailing-thousands-of-hijacked-major-brand-subdomains-found-bombarding-users-with-millions ↩︎ ↩︎ ↩︎