Guides

Parsing Authentication-Results: RFC 8601 versus two real edges

The grammar is regular and the headers in your inbox are not. Three measured variances, why splitting on a semicolon is wrong, and the rule that survives them.

Yowl parses this for you, across two different receiving edges, so you never see any of it. The verdict on every message — was the From domain actually bound by DKIM or DMARC, or did only the envelope pass — is computed at ingest and kept per correspondent.

What a parsed verdict is actually for

Worth saying, because “we check your SPF and DKIM” is what every mail host claims and it is nearly useless on its own. A single message’s authentication result tells you almost nothing: plenty of legitimate organisations publish no DMARC at all, so a parser that quietly downranks them is a spam filter you did not mean to write. Ours gates nothing.

What the verdict is worth is as history per correspondent. Held across every message someone has ever sent you, it becomes a baseline — and a baseline is the only thing that makes a change visible.

That is what Yowl actually sells on top of it. A classifier summary says what a mail is, never what changed, which is why the single most dangerous email a business receives reads as an ordinary invoice: the same supplier, the same template, new banking details. So the change is tracked separately — a per-correspondent alert raised when someone states a detail inconsistently across their mail, surfaced on the conversation and in the inbox index, and carried through to the agent reading it with an explicit instruction to never act on changed payment details from email alone and to tell a human to verify out of band.

The alert never carries the claimed value itself, because that value is attacker-written text and putting it in front of an AI is how you get the AI to act on it.

None of that is possible without getting the parsing right first, which is the rest of this page.

Three places ours had to be wrong before it was right

RFC 8601 defines a tidy grammar. Inbound mail does not obey it. Measured on real raw MIME from two production receiving paths on 2026-09-16.

⛔ One thing to settle before the parser: the common advice to “trust the topmost Authentication-Results” was never what the spec says, and it breaks the moment you have more than one way of receiving mail.

First: match the authserv-id, and match it exactly

The spec’s rule is to trust a header whose authserv-id — the token immediately after the colon — is one you configured. Not “the first one”, not “the topmost”.

Two paths in, two legitimate ids:

Authentication-Results: mx.cloudflare.net; dkim=pass header.d=sender.example ...
Authentication-Results: amazonses.com; spf=pass (...) client-ip=...; envelope-from=...

⛔ Compare with equality, not startsWith. amazonses.com.attacker.example has amazonses.com as a prefix, and a sender can put whatever they like in a header before it reaches you. We shipped the prefix version, which is a lie waiting to be written into your own records. Suffix matching is no better: amazonses.com does not imply email.amazonses.com unless that is an edge you actually run.

Second: the semicolon is not a separator, it is an introducer

This is the one that costs an afternoon. The natural instinct is header.split(';'), and the grammar looks like it agrees. It does not:

resinfo = [CFWS] ";" methodspec [ CFWS reasonspec ] [ CFWS 1*propspec ]

The ";" only ever opens a result block. Inside a block the properties are separated by CFWS — whitespace and comments — never by semicolons. So spf=pass smtp.mailfrom=a.example smtp.helo=b.example is one block with two properties.

Now the header we actually receive on one of our two edges:

spf=pass (spfCheck: domain of sender.example designates 198.51.100.20 as permitted sender)
  client-ip=198.51.100.20; envelope-from=bounce@sender.example; helo=a8-52.smtp-out.example;

Semicolons inside the spf= block. ⚠ That is off-spec, not a dialect — worth saying plainly, because the workaround below is compensation for a provider’s bug and not a subtlety of the standard. Split on ; and you get three fragments that look like result blocks and are not.

The recovery rule, and the version of it that is too weak

Our first attempt: a fragment starts a new block if its first token looks like word=word, otherwise it continues the previous one. That survives client-ip=198.51.100.20 and envelope-from=bounce@…, both of which have characters no method name has.

It does not survive a property whose name and value are both plain letters:

helo=mail        → read as method "helo", result "mail"
reason=good      → read as a new block, when reason belongs to the current one

The rule that holds is to check both halves against the registries rather than against a shape: a fragment opens a new block only if its method is in IANA’s Email Authentication Methods registry and its result is in Email Authentication Result Names (pass fail softfail neutral none temperror permerror policy, plus per-method additions). Anything else is a property of the block in progress.

Also unfold the header first. [CFWS] is permitted at very nearly every position in that grammar, so parsing line by line will bite you on a folded dkim= block eventually.

Third: the same fact has different names, and two of them are not in the registry

It is tempting to treat the two edges as spelling variants. They are not — there are three separate things going on:

What you want Spec property Edge A Edge B Same meaning?
DKIM signing domain header.d header.d=sender.example header.i=@sender.example No — see below
SPF envelope domain smtp.mailfrom smtp.mailfrom=… envelope-from=… Yes, but B is off-registry
connecting IP (none defined) — client-ip=… Bare property, not in the registry

header.d and header.i are different DKIM tags. d= is the signing domain. i= is the signing identity — a subdomain-or-equal of d=, and it may carry a local part, so the value can be @example.com or user@example.com. Because i= is optional in a signature and defaults to @d, the two often correspond, and code that reads them as interchangeable will still be wrong on a signature that sets i= deliberately. Strip the local part before comparing anything.

smtp.mailfrom is the registered property; envelope-from is not. The lowercase-and-strip-spaces convention is defined in the spec — MAIL FROM becomes mailfrom under the smtp ptype — so envelope-from is an invention that happens to be readable. Accept it, prefer the registered name, and do not present them as equals in your own output.

What to do with the verdict

Whatever you compute, be careful what you let it decide. We keep a per-message verdict — the From domain was bound by DKIM or DMARC, or only the envelope passed, or neither — and it feeds display and per-correspondent history. ⛔ It gates nothing. A great many legitimate organisations still publish no DMARC, and a parser that quietly downranks them is a spam filter you did not mean to write.

Connect a domain to Yowl →