Wraps Logo
Security13 min readWraps TeamJuly 9, 2026

DMARC Is Finallyan Actual Standard

RFC 9989 published in May 2026 and obsoleted the Informational RFC 7489. The Public Suffix List is out, pct= is gone, and np= is in. Wraps ships the parser and generator changes today in @wraps.dev/email-check@1.1.0.

Standards Track, May 2026
Tree walk replaces the PSL
pct= removed, np= and t= added

DMARC was never an IETF standard. Now it is.

If you have ever argued with a mailbox provider about DMARC behavior, you have run into the awkward part: RFC 7489 was published as Informational. It described what the industry had converged on. It was not a specification you could hold anyone to.

That ended in May 2026. RFC 9989 carries Category: Standards Track and Obsoletes: 7489, 9091 in its header. DMARC is a Proposed Standard. The normative keywords in it are real ones, and the tag registry it defines is authoritative — per Section 4.7, only tags in that registry are to be processed, and unknown tags MUST be ignored.

The work — known throughout its life as DMARCbis, and finalized as draft-ietf-dmarc-dmarcbis-41 — shipped as three documents, not one:

RFC 9989

The core protocol. Policy records, discovery, identifier alignment, the tag registry.

RFC 9990

Aggregate Reporting. What rua= destinations actually receive.

RFC 9991

Failure Reporting. What ruf= destinations actually receive.

The second obsoletion is easy to miss. RFC 9091 was the experimental Public Suffix Domain extension — the one that gave us np= and the notion of a registry operator publishing policy on behalf of everything under it. It is no longer an experiment. It is folded into the base standard as the psd= tag.

The tree walk replaces the Public Suffix List

This is the largest architectural change in the document, and it is the one nobody put in a release note.

DMARC has always needed to answer one question before it can do anything else: what is the Organizational Domain for this name? That answer decides which record applies and whether an authenticated identifier is aligned. Under RFC 7489 the answer came from a Public Suffix List. RFC 9989 Section 4.10 explains, at some length, why that was a problem:

“[RFC7489] mandated no requirement for a specific PSL for Mail Receivers to use (though it did suggest the one found at https://publicsuffix.org/) nor did it provide any guidance for the frequency of regular retrieval of the PSL... [RFC7489] acknowledged the possibility of interoperability issues caused by Mail Receivers choosing different PSLs”

— RFC 9989 §4.10

Two receivers with two different snapshots of a list maintained outside the IETF could legitimately disagree about your Organizational Domain, and therefore about whether your mail was aligned. RFC 9989 replaces the list with something every receiver already has: DNS.

“Rather than just using a PSL to help identify an Organizational Domain, this update defines a discovery technique known colloquially as the ‘DNS Tree Walk’.”

— RFC 9989 §4.10

How the walk works

Section 4.10 lays it out in seven steps. Query _dmarc.<target> for a TXT record in DMARC Policy Record format. Discard anything that does not start with a v tag for the current version. If a single record remains and it carries psd=n or psd=y, stop. Otherwise count the labels, strip the leftmost one, and query again — repeating until you run out of labels.

There is one shortcut in the middle of that loop, and the RFC is refreshingly blunt about why it exists:

“the potential exists for an ill-intentioned Domain Owner to send mail with Author Domains with tens or even hundreds of labels for the purpose of executing a denial-of-service attack on the Mail Receiver. To guard against such abuse of the DNS, a shortcut is built into the process so that Author Domains with more than eight labels do not result in more than eight DNS queries. Observed data at the time of publication showed that Author Domains with up to seven labels were in usage”

— RFC 9989 §4.10

So a name with fewer than eight labels strips one label at a time. A name with eight or more jumps straight down to seven remaining labels and continues from there. The RFC works the pathological case through in full:

Author Domain: a.b.c.d.e.f.g.h.i.j.mail.example.com

_dmarc.a.b.c.d.e.f.g.h.i.j.mail.example.com
_dmarc.g.h.i.j.mail.example.com
_dmarc.h.i.j.mail.example.com
_dmarc.i.j.mail.example.com
_dmarc.j.mail.example.com
_dmarc.mail.example.com
_dmarc.example.com
_dmarc.com

= 8 queries, the hard cap

Eight is a ceiling, not a cost. A normal two- or three-label domain that publishes its own record resolves in one query and stops. Nobody is paying eight lookups per message.

It runs more than once per message

Section 4.10 opens by naming two distinct uses for the walk. The first is the obvious one: policy discovery (Section 4.10.1), when the Author Domain has no record of its own and the receiver needs to find the one it inherits from. The second is the one people forget — identifier alignment evaluation (Section 4.10.2), deciding whether the SPF- or DKIM-authenticated identifier and the Author Domain share an Organizational Domain. A receiver may run the walk several times while evaluating a single message.

Once the walk has collected records, Section 4.10.2 picks the Organizational Domain by going from the longest name to the shortest. A record with psd=nwins immediately — that name is the Organizational Domain, stop looking. A record other than the starting point carrying psd=y means the Organizational Domain is the domain one label below it. Failing both, it is the record found at the name with the fewest labels. If the process determines nothing at all, the initial target domain is the Organizational Domain.

That first rule is the interesting one. psd=n is an escape hatch: per Section 4.7, it declares the domain is not a PSD and is the Organizational Domain for itself and its subdomains. A large organization whose boundary the PSL got wrong can now assert its own boundary in its own DNS instead of filing a pull request against a list. The default is psd=u — unknown, run the walk.

Two DMARC records at one name means zero DMARC records.

Step 2 of the walk is explicit: if multiple DMARC Policy Records are returned for a single target, they are all discarded. Not “the first one wins”, not “merge them” — discarded. And per Section 4.10.1, if the walk finds no record, Mail Receivers MUST NOT apply the DMARC mechanism to the message. A duplicated TXT record left behind by a migration silently turns your policy off.

Two things this change is not. It is not a deprecation of the Public Suffix List — RFC 9989 replaces the PSL for DMARC Organizational Domain discovery and says nothing about the list’s many other consumers. And it is not a new obligation on you. There is no requirement that you publish a “tree-walk discoverable” record. Section 5.1.4 notes that if the Organizational Domain differs from the Author Domain, a record also needs to be published for the Organizational Domain. That is a publishing recommendation, and the MUSTs in this area all land on the receiver.

pct= is gone

Not softened. Not discouraged in prose. Removed.

Section 4.7 lists the complete set of valid DMARC tags: adkim, aspf, fo, np, p, psd, rua, ruf, sp, t, v. pct is not on it. The IANA registry in Section 9.3 lists it as historic, with the reference column still pointing at RFC 7489 — IANA’s way of saying this tag belongs to the old document. The registry defines that status as meaning “the tag is considered deprecated and is not expected to be in use in any current implementation”.

Combine that with the unknown-tag rule from the same section — only tags in the registry are to be processed, unknown tags MUST be ignored — and a pct=25 in your record is now just text a conforming receiver steps over.

Why, in the RFC’s own words

“Operational experience showed that the ‘pct’ tag was usually not accurately applied, unless the value specified was either 0 or 100 (the default), and the inaccuracies with other values varied widely from one implementation to another.”

— RFC 9989, Appendix A.6

Read that again if you have ever staged a rollout at pct=25. The percentage was not being applied the way you assumed, and it was being misapplied differently by every receiver. The dial you thought you were turning was painted on.

What survived is the one value that did something reliable. Appendix A.6 records that some intermediaries treated pct=0 as a signal to rewrite the RFC5322.From header, which meant a Domain Owner could compare aggregate reports before and after setting it and infer how much of its traffic flowed through non-rewriting intermediaries. That side effect was worth keeping. The tag wrapped around it was not:

“it didn’t make sense to support a tag named ‘pct’ that had only two valid values. This version of the DMARC mechanism, therefore, introduces the ‘t’ tag as shorthand for ‘testing’, with the valid values of ‘y’ and ‘n’, which are meant to be analogous in their application by mailbox providers and intermediaries to the ‘pct’ tag values ‘0’ and ‘100’, respectively.”

— RFC 9989, Appendix A.6

t= steps your policy down one level

Section 4.7 defines t as DMARC policy test mode, optional, defaulting to n. The semantics are more precise than “testing” suggests:

“if the policy is ‘quarantine’ and the value of the ‘t’ tag is ‘y’, a policy of ‘none’ will be applied to failing messages; if the policy is ‘reject’ and the value of the ‘t’ tag is ‘y’, a policy of ‘quarantine’ will be applied to failing messages”

— RFC 9989 §4.7

t=y is not an off switch.

p=reject; t=y still quarantines failing mail. If you meant “observe and do nothing”, you meant p=quarantine; t=y or plain p=none. Section 4.7 also notes the tag has no effect on any policy that is already none, and does not affect the generation of DMARC reports — so your aggregate feed keeps flowing while you test.

np=, sp=, and what “non-existent” means

These two tags carve the subdomain space in half. np takes the names that do not exist; sp takes the ones that do.

Section 4.7 defines np as the Domain Owner Assessment Policy for non-existent subdomains of the given Organizational Domain. Its syntax is identical to p. It applies only to non-existent subdomains — not to existing subdomains, and not to the domain itself. When it is absent, the policy from sp applies if sp is present, and the policy from p applies if it is not.

That inheritance chain is why np is the cheapest hardening available to most domains. Attackers do not need a real subdomain to spoof you; they need a plausible-looking one. np=reject closes the entire space of names you never created, without touching any name you did.

“Non-existent” is narrower than you think

Appendix A.4 defines the test, and it is deliberately permissive:

“The DMARC mechanism makes no such requirement for the existence of specific DNS RRs in order for a domain to exist; instead, if any RR exists for a domain, then the domain exists.”

— RFC 9989, Appendix A.4

The appendix spells out the DNS distinction that this rests on: an NXDOMAIN response means the name does not exist, while a NODATA response — rcode NOERROR with no records of the queried type — means the type does not exist but the name does. It also carries the RFC 8020 consequence forward: if a query for a name returns NXDOMAIN, every name below it in the hierarchy also does not exist.

Note what the RFC declined to do. It does not use the narrower “resolvable” definition from RFC 5321, which would have required MX, A, or AAAA records. A lone TXT record makes a name exist. A stale CNAME makes a name exist. Which means npwill quietly not apply to a large number of names you assumed it covered — and sp will.

sp= got narrower, and gained a footgun

Section 4.7 now scopes sp to mail using an existing subdomain of the prevailing Organizational Domain, and not to the Organizational Domain itself. The non-existent half was carved out into np. The inheritance rule you already know still holds: with both sp absent and np absent or not applicable, the p policy MUST be applied for subdomains.

The part worth writing on a sticky note is the last sentence of that definition:

“Note that ‘sp’ will be ignored for DMARC Policy Records published on subdomains of Organizational Domains and PSDs due to the effect of the DMARC policy discovery (Section 4.10.1).”

— RFC 9989 §4.7

Publishing sp=reject at _dmarc.mail.example.com does nothing. It parses, it validates, it will show up green in most checkers, and it has no effect. Subdomain policy is a property of the Organizational Domain record.

p= is now RECOMMENDED, not required

This one is under-reported and it changes what counts as a valid record. Section 4.7 marks p as RECOMMENDED for DMARC Policy Records, and adds that if the tag is not present in an otherwise syntactically valid record, the record is treated as if it included p=none.

Section 4.10.1 makes the receiver behavior normative. If a retrieved record has no valid p tag — or has an sp or nptag that is not valid — and a rua tag is present containing at least one syntactically valid reporting URI, the Mail Receiver MUST act as if a record containing p=none was retrieved and continue processing. Without that valid rua, the receiver applies no DMARC processing to the message at all.

; valid under RFC 9989. means p=none.
_dmarc.example.com. IN TXT "v=DMARC1; rua=mailto:dmarc@example.com"

; also valid: a broken p= with a working rua= degrades to p=none
_dmarc.example.com. IN TXT "v=DMARC1; p=bogus; rua=mailto:dmarc@example.com"

; no DMARC processing at all: broken p=, no rua= to fall back to
_dmarc.example.com. IN TXT "v=DMARC1; p=bogus"

The practical read: a monitoring-only record no longer needs to say so. It needs somewhere to send the reports. We will come back to this one — Wraps’ own parser gets it wrong today.

Reporting moved out of the core document

rua and ruf are still active tags, but the formats they produce are no longer defined in the DMARC document. Section 4.7 points rua at RFC 9990 and ruf at RFC 9991. Both also gained a MUST: if the tag is not provided, Mail Receivers MUST NOT generate reports of that kind for the domain, and URIs using schemes a receiver does not support MUST be ignored.

Two tags did not survive the move. Section 9.3 marks both rf (failure report format) and ri (aggregate reporting interval) as historic, and neither appears in the Section 4.7 valid tag list. ri is the sharper loss of the two: report cadence is no longer something a sender asks for in its policy record. If you have been setting ri=3600 and wondering why reports still arrive daily, that question is now settled by removal.

fo survives unchanged in shape — colon-separated values from 0, 1, d, s, defaulting to 0— with one clarification worth knowing: Section 4.7 says its content MUST be ignored if a ruf tag is not also specified. A record with fo=1 and no ruf= is doing nothing.

The tag registry, after RFC 9989

v=active

Version. Must be DMARC1. Records not starting with a v tag for the current version are discarded during discovery.

p=active

Domain Owner Assessment Policy. Now RECOMMENDED rather than required.

sp=active

Policy for existing subdomains only. Ignored on records published at subdomains of Organizational Domains and PSDs.

np=active

Policy for non-existent subdomains. Falls back to sp, then p, when absent.

t=active

Test mode. t=y applies the policy one level down. Default n.

psd=active

Public Suffix Domain flag: y, n, or u. Default u. Steers the tree walk.

adkim=active

DKIM identifier alignment mode: s or r.

aspf=active

SPF identifier alignment mode: s or r.

rua=active

Aggregate report destinations. Report format is now defined by RFC 9990.

ruf=active

Failure report destinations. Report format is now defined by RFC 9991.

fo=active

Failure reporting options: 0, 1, d, s. Content MUST be ignored if ruf is not also specified.

pct=historic

Sampling rate. Removed from the specification; the IANA reference still points at RFC 7489.

rf=historic

Failure report format.

ri=historic

Aggregate reporting interval.

What did not change, and one new protection

The advice has not moved. p=none is still where Section 5.1.4 tells you to start, paired with a rua pointing at a mailbox you actually read. It is still not protection. But RFC 9989 added a normative guarantee around it that RFC 7489 did not have:

“To enable Domain Owners to receive DMARC feedback without impacting existing mail processing, discovered policies of ‘p=none’ MUST NOT modify existing mail handling processes.”

— RFC 9989 §5.4

That is a floor, not a ceiling: publishing monitoring mode is guaranteed not to make your deliverability worse than publishing nothing. It removes the last excuse for not turning DMARC on tomorrow. It does not make p=none do anything for you.

The same section applies a check in the other direction, at anyone feeling triumphant about reaching p=reject: Mail Receivers SHOULD NOT reject messages solely because of a published policy of reject, and should apply other knowledge and analysis to avoid rejecting legitimate messages sent in ways DMARC cannot describe, harming mailing lists, and similar. Your policy is an input to a decision, not the decision.

And on the timeline, the RFC is sober. Section 5.1.7 says that depending on sending cadence, it may take many months of consuming aggregate reports before a Domain Owner is sure it is properly authenticating all of its mail. If a vendor promises you p=reject in a fortnight, that promise is not coming from the spec. The four-step ramp is covered in detail in Your DMARC Policy Is Useless; nothing in RFC 9989 changes the shape of it, only the tags you use along the way.

The record, before and after

Here is what a Wraps-generated DMARC record looked like before today, and what it looks like now:

; before
_dmarc.example.com. IN TXT "v=DMARC1; p=quarantine; rua=mailto:postmaster@mail.example.com"

; after
_dmarc.example.com. IN TXT "v=DMARC1; p=quarantine; sp=quarantine; np=reject; rua=mailto:postmaster@mail.example.com"

Two additions, and only one of them changes behavior. sp=quarantine is a no-op in policy terms — Section 4.7 already says an absent sp inherits from p, so writing it out changes nothing except how the record reads. We write it because a record that states its subdomain policy is a record nobody has to reason about at 2am.

np=reject is the real change. Under the old record, mail from a subdomain that has never existed inherited p=quarantine — a spoofed billing.example.com landed in the junk folder, where people read it. Now it is rejected at the door.

What Wraps shipped today

Two packages went out this morning: @wraps.dev/email-check@1.1.0 (the library) and mail-audit@1.2.0 (the standalone CLI that bundles it). The change has two halves: the checker learned the new tags, and every place Wraps writes a DMARC record started writing the new one.

The parser

np=, t=, and psd= are recognized

np is validated against the same three values as p and surfaced as nonExistentSubdomainPolicy; an invalid value gets a warning rather than being dropped silently. t=y sets a testing flag. psd is validated against y/n/u.

pct went from a knob to a nudge

It used to cost you 2 points when set below 100, on the theory that you were under-enforcing. That was the wrong frame: pct is not a weaker policy, it is a tag receivers no longer read. It is now a 1-point deprecation nudge with a warning that tells you to use t=y while testing instead.

np=reject earns a point; missing np earns a warning

Any record with an enforcing policy and no np= now gets a recommendation. Set np=reject on an enforcing policy and you get a +1 bonus. Be clear-eyed about what that buys: the letter grade is set by whether SPF, DKIM, and an enforcing DMARC are all present, and bonuses move you within a band, never across one. It will not turn a B into an A. It is worth exactly one point, and it is the right thing to do anyway.

Eight new tests, sixty green

Five parser cases and three scoring cases. One of them pins the pct demotion at exactly 1 point, so a future refactor cannot quietly promote it back to a penalty.

The generator

The new record string replaced the old one in fifteen places: Pulumi’s Route53, Cloudflare, and Vercel DNS resources; the CDK email construct; the CLI’s Route53 preview and both create paths, its Cloudflare and Vercel paths, and its manual record builder; the CLI’s success and status output; the console’s copy-to-clipboard button; the CLI README; and the domain verification and Vercel setup guides in the docs. If Wraps tells you a DMARC record to publish, it is the DMARCbis one.

Why np=reject is safe as a default here

Defaulting a customer’s domain to a reject policy deserves an argument, so here it is. nponly applies to subdomains that return NXDOMAIN. Wraps’ MAIL FROM subdomain always carries MX and SPF records — that is how Wraps provisions it — and per Appendix A.4, any RR at a name makes that name exist. The subdomain Wraps sends from is therefore, by construction, not a name np can reach. np=reject cannot block Wraps sending.

It is still a behavior change on your domain.

The safety argument covers the subdomain Wraps provisions. It does not cover yours. If some system sends as a name that exists only in a spreadsheet, or as a name that started returning NXDOMAIN when someone pruned a CNAME last year, np=reject will start rejecting it. That is the tag working correctly. It is also mail that used to be delivered. Read your aggregate reports before you assume the answer.

How to see it on your own domain

The standalone CLI is unscoped on npm and needs no account:

# full deliverability audit
npx mail-audit example.com

# the DMARCbis fields live here
npx mail-audit example.com --json

# same checks, via the Wraps CLI
wraps email check example.com

That --json is not optional advice. The human-readable output of both CLIs prints policy, reporting, and alignment, and stops there:

  ✓ DMARC            v=DMARC1; p=reject; rua=mailto:postmaster@mail.wraps.dev
                     Policy: reject • Reporting: enabled • Alignment: relaxed

No np, no t, no psd. The DMARCbis deductions are all one or two points and the ISSUES section only renders deductions of five or more, so they do not appear there either. The pretty output has not caught up with the parser yet. The structured output has:

{
  "nonExistentSubdomainPolicy": null,
  "testing": false,
  "psd": null,
  "warnings": [
    "No np= set — add np=reject to block spoofing from non-existent subdomains (DMARCbis)"
  ]
}

The same fields come back from the public checker API, which needs no auth and is rate-limited by IP:

curl https://api.wraps.dev/tools/email-check/example.com

Or skip the install entirely and use the checker on the site, where the DMARC panel now has a Non-Existent Subdomains tile in the slot the old pct= tile used to occupy. When np is absent it renders np=inherit, which is the honest description of what happens.

Run it against wraps.dev and our own checker flags us. Our record is v=DMARC1; p=reject; rua=mailto:postmaster@mail.wraps.dev — no sp=, no np=. The generator change applies to records Wraps creates going forward; it does not reach back and rewrite records that already exist, including ours.

What “DMARCbis-aware” does not mean

Being explicit about the surface area, because “RFC 9989 support” is going to appear on a lot of marketing pages this year and most of it will mean less than this does:

There is no tree walk

The record lookup does exactly one query — _dmarc.<domain> — and returns the first TXT record starting with v=DMARC1. No parent walk, no eight-label cap, no Organizational Domain discovery. If you check a subdomain that inherits its policy from a parent, Wraps will tell you there is no DMARC record. That is the largest gap on this list.

psd= is parsed and then ignored

The value is validated and reported. It steers nothing, because there is no Organizational Domain selection for it to steer.

Duplicate records are not discarded

Section 4.10 says multiple DMARC Policy Records at one name are all discarded. Wraps takes the first match and moves on. If you have two _dmarc TXT records, a conforming receiver sees no policy and Wraps sees your first one. Wraps is more optimistic than the standard here, and the standard is right.

A record with no p= is reported as invalid

As covered above, RFC 9989 §4.10.1 says v=DMARC1; rua=mailto:x@example.com MUST be treated as p=none. Wraps still treats a missing p as a hard parse error. That is a conformance bug on our side, not a finding about your domain.

rf= and ri= get no deprecation warning

Both are historic in the IANA registry. Both are still parsed as live tags with no notice. The treatment pct got was not extended to them.

t=y is described loosely

Our warning says receivers will not enforce the policy. Section 4.7 says they apply it one level down. Directionally right, technically wrong, and on the list to fix.

It never evaluates a message

This is a DNS record auditor, not a mail receiver. Identifier alignment, the second use of the tree walk, is not something it does or could do — there is no message in scope. Everything above is about what you published, not about what happens to mail.

One more limit worth naming, on the standard rather than on us: we have not verified which mailbox providers honor np=, t=, or the tree walk today, and RFC 9989 does not tell you. Publishing the tags is correct because the standard defines them. Assuming a specific receiver acts on them is a different claim, and not one we are making.

Sources

  • RFC 9989 — Domain-Based Message Authentication, Reporting, and Conformance (DMARC), May 2026. Standards Track. Obsoletes 7489, 9091. Every section cited above.
  • RFC 9990 — DMARC Aggregate Reporting, May 2026.
  • RFC 9991 — DMARC Failure Reporting, May 2026.
  • draft-ietf-dmarc-dmarcbis — IETF Datatracker history. Final revision 41; status “RFC - Proposed Standard (May 2026)”.

Continue reading

Check your record against RFC 9989

One command, no account, no data kept. It will tell you whether you are still shipping a pct= you think is doing something.

npx mail-audit example.com --json
Run It in the Browser