Email header forensics: how to read a spoof
A supplier emails new banking details and it reads right. How to read Authentication-Results, the Received chain and Return-Path, and find where it came from.
Someone in your finance team forwards you a message. A supplier they have paid for three years has changed their banking details, and the invoice is due Friday. The display name is right. The signature is right. The wording is right, because whoever wrote it had read the last six emails in the thread.
The body of that message will not tell you whether it is real. The headers will.
They will also tell you which of three problems you have, and the three need different phone calls. Somebody is spoofing the supplier's domain from infrastructure that has nothing to do with them. Somebody is sending through infrastructure the supplier authorised and no longer controls. Or somebody is sitting inside the supplier's actual mailbox, in which case every check passes and the headers are still the thing that tells you.
Where do I find the raw headers?
The forwarded copy is not good enough. A normal forward is a new message: its headers describe the forward, and whatever survives of the original is text in the body. Ask for the original, or pull it yourself.
- Gmail and Google Workspace: open the message, the three-dot menu, Show original. That page has a "Copy to clipboard" button.
- Outlook desktop: open the message in its own window, File → Properties, and copy the "Internet headers" box.
- Outlook on the web and the new Outlook: three dots, View → View message details.
- Exchange Online, when the mailbox has already deleted it: message trace in the Defender portal gives you the routing but not the full header set. If you need the headers, you need a copy of the message.
You want everything from the top of the file down to the first blank line. The body is not part of this.
What should I read first?
Read Authentication-Results, then Return-Path, then From:, before anything else in the file.
Authentication-Results: what the receiver decided
Return-Path: where bounces actually go
From: what the reader sees
From: is the author identity the reader sees, and the only one of the three your finance team ever looks at. That gap is the whole attack.
Two more lines carry identity. Sender: names the mailbox that actually submitted the message, and it appears legitimately whenever a service sends on somebody's behalf, so a Sender: that differs from From: is ordinary rather than suspicious. Message-ID: is not evidence of origin: the sending application writes it and can put any domain after the @. It is useful for matching one message against another from the same sender, and for nothing else.
What does Authentication-Results actually tell me?
It is the receiving server's verdict, written at the moment of delivery, and usually the most useful line in the file, provided the receiver that wrote it is one you trust. Check the name at the front of it, mx.google.com below. Anybody upstream can write an Authentication-Results header, so RFC 8601 §5 requires a border MTA to delete any instance that claims to have been added inside its own trust boundary and did not come from a server it trusts. Read the ones your own infrastructure wrote. Treat the rest the way you treat the Received chain below them. A healthy one looks like this:
Authentication-Results: mx.google.com;
spf=pass smtp.mailfrom=bounce.supplier.co.za;
dkim=pass header.d=supplier.co.za header.s=s1;
dmarc=pass (p=REJECT) header.from=supplier.co.za
Read the identifiers, not the words. spf=pass on its own says the sending IP was allowed to send for whatever is in smtp.mailfrom, which may be a domain the attacker owns and has configured perfectly. dkim=pass says a signature verified for whatever is in header.d, which again may be the attacker's domain. Only dmarc=pass ties either of those back to the domain in the visible From: header. That tie is identifier alignment, defined in RFC 7489 §3.1, and it is the thing you are checking. Alignment is relaxed by default, so bounce.supplier.co.za aligns with supplier.co.za on the organisational domain. Strict alignment, aspf=s or adkim=s in the DMARC record, demands an exact match and almost nobody publishes it. Relaxed is why a legitimate ESP can use its own return-path and still pass.
Our take: a message with spf=pass dkim=pass dmarc=fail is more suspicious than one that fails everything, not less. It means somebody built infrastructure that authenticates properly and then wrote your supplier's name on the envelope.
How do I read the Received chain?
Each relay prepends its own Received: header to the top, so the chain reads newest first. The bottom is where the message entered the mail system, the top is your own gateway, and three rules make the rest of it useful.
- Read from the bottom up to find the origin. The last
Received:in the file is usually the first hop that happened, and only ever the earliest hop still visible. Systems strip and rewrite, so treat it as the oldest surviving record rather than the beginning of the story. RFC 5321 §4.4 is what obliges each relay to prepend rather than append, and it is why the chain reads backwards. - Trust nothing below your own trust boundary. Anything an attacker's server wrote, an attacker chose. Received headers are plain text appended by whoever handled the message, so a fabricated hop claiming the message came from inside your organisation costs nothing to write. The first header added by a server you control or pay for is where evidence starts. Everything below it is a claim.
- Check the clocks, but only for large gaps. Clocks drift, so a minute or two of disagreement means nothing, and so does a queue that held the message for an hour. A chain whose timestamps run backwards is a chain that needs explaining, not proof that somebody edited it. Bad timezone offsets, a virtual machine whose clock has slipped and a relay that stamps the time at the wrong end of its own processing all do this. Convert everything to UTC before you decide the gap is impossible.
What does a spoofed invoice actually look like in the headers?
Six things expose it, and none of them is in the body. Here is an anonymised version of the message, cut to the lines that carry the finding.
Return-Path: <billing@vps-host-77.example.net>
Received: from mail.vps-host-77.example.net (mail.vps-host-77.example.net. [203.0.113.42])
by mx.google.com with ESMTPS id x14si2938471wrn.402
for <finance@clientco.example>
(version=TLS1_2 cipher=ECDHE-ECDSA-AES128-GCM-SHA256);
Tue, 12 Aug 2026 04:31:12 -0700 (PDT)
Received-SPF: fail (google.com: domain of billing@vps-host-77.example.net
does not designate 203.0.113.42 as permitted sender)
client-ip=203.0.113.42;
Authentication-Results: mx.google.com;
dkim=none;
spf=fail smtp.mailfrom=billing@vps-host-77.example.net;
dmarc=fail (p=NONE sp=NONE dis=NONE) header.from=northwind-traders.example
Received: from localhost (unknown [10.0.0.9])
by mail.vps-host-77.example.net (Postfix) with ESMTPA id 4Wq8Yz2Ck1z3xVn;
Tue, 12 Aug 2026 13:02:44 +0200 (SAST)
Date: Tue, 12 Aug 2026 13:02:44 +0200
To: <finance@clientco.example>
Subject: Updated banking details for the August invoice
From: "Accounts | Northwind Traders" <accounts@northwind-traders.example>
Reply-To: <northwind.accounts.recovery@gmail.com>
Message-ID: <a4f1c9d0-2b77-4e51-9c6e-7a1f0e33bb18@mailer-out-3.example.org>
X-PHP-Originating-Script: 1001:sendmail.php
Return-Path sits at the top because the delivering server puts it there, at final delivery, ahead of its own Received line (RFC 5321 §4.4). Six things stand out, and the reader saw none of them.
From:andReturn-Path:are different domains. The reader seesnorthwind-traders.example. Bounces go tovps-host-77.example.net. That split is structural. It is where the mail system thinks the message came from, and it is the fastest tell in any header set.dmarc=failwithp=NONE. The supplier's domain publishes a DMARC record with no enforcement, so Google recorded the failure and delivered the message anyway. Ap=rejectpolicy on that domain would have stopped this at the gateway. This is exactly what the none-to-reject ladder is for.dkim=none. Not a failure, an absence. Nothing was signed at all. On its own that is weak evidence, because a forwarder can strip or break a signature that was there when the message was sent. Here it is not on its own: the chain shows a two-hop path from a PHP script to Google with no forwarder in it, so there is no intermediary to blame.Reply-To:on a free-mail domain. Reply to that invoice query and the answer comes from the attacker, in a thread that looks like your own. Legitimate senders do this too (ticketing systems, shared inboxes), so read it next to the authentication result rather than on its own.- A private IP relayed by a public host.
from localhost (unknown [10.0.0.9])handed to a public VPS. Readwith ESMTPAnext to it: the trailingAsays the VPS recorded an authenticated SMTP submission, so something logged in with real credentials to send this. That is the fingerprint of an application using stored credentials or a submission account somebody else now has, and it is a different problem from a forged hop. Either reading is worth chasing. X-PHP-Originating-Script. The sending server volunteered that a PHP script calledsendmail.phpproduced this message. Plenty of legitimate self-hosted mail leaves the same fingerprint, so on its own it corroborates rather than proves. Paired with the five above, it is consistent with a web application generating the message rather than a corporate mail system, which makes the VPS the thing to go and look at.
Run those headers through the email header analyser and it returns a likely_phishing verdict with DMARC_FAILED, SPF_HARD_FAIL, REPLY_TO_DIFFERENT_DOMAIN, PRIVATE_IP_IN_PUBLIC_PATH and SERVER_LEAK_HEADERS. Five of the six above, in about a second, which matters when the person asking you is holding a payment deadline. The sixth is inside the first: the From and Return-Path split is the reason DMARC_FAILED fired at all.
What does the source IP tell me on its own?
More than the rest of the chain, because the sender did not write it. They chose the VPS. They did not get to choose what Google wrote down about the connection.
client-ip=203.0.113.42 came off the TCP session, not out of the message. That makes it observed rather than asserted, which is a different class of evidence from every hostname above it. It identifies the machine that connected to Google, which is not necessarily the machine, or the person, the message started at. Take it to a reverse DNS lookup and check whether the PTR name resolves back to that same IP. That is forward-confirmed reverse DNS, a consistency check many receivers weigh when they decide how much to trust a sending host. It says something about infrastructure, not about honesty: bulk senders and real mail systems usually have clean FCrDNS, compromised hosting accounts frequently do not, and an attacker who bothered can have both.
Then check the address against the blocklists that gate real delivery, a much shorter set than most blacklist checkers imply and one the blocklist post works through. An IP already on Spamhaus CSS or XBL, sending mail that fails DMARC for the domain in the From:, has stopped being a mystery. The listing is about the host's history rather than about this message, so it corroborates the finding rather than making it.
The order matters: the IP, then its PTR, then whether that PTR resolves back, then who owns the range. Every name in a header was typed by somebody. The IP was recorded by the machine that accepted the connection.
What if a forwarder broke the authentication?
Then spf=fail or a broken DKIM signature may be telling you about the forwarder rather than the sender. A mailing list rewrites the envelope and often edits the body, and both break the checks a receiver runs at the last hop.
ARC, the Authenticated Received Chain (RFC 8617), exists for this. Each intermediary that participates records what it saw and signs the record, so a receiver can read authentication results from before the forward:
ARC-Authentication-Results: i=1; mx.listserver.example;
spf=pass smtp.mailfrom=supplier.co.za;
dkim=pass header.d=supplier.co.za;
dmarc=pass header.from=supplier.co.za
ARC-Message-Signature: i=1; a=rsa-sha256; d=listserver.example; s=arc; ...
ARC-Seal: i=1; a=rsa-sha256; d=listserver.example; s=arc; cv=none; ...
Read i= for the instance number, and cv= on the highest ARC-Seal for whether the chain still validates: cv=fail means the receiver stopped trusting the forwarded results. If the chain is intact and the results in it are clean, a failure at your own gateway is the forward, not the sender.
Whether that saves the message is a decision your receiver makes. Microsoft only honours a chain from a sealer you have explicitly trusted, and stamps compauth reason 130 when a trusted seal overrides a DMARC failure. Plenty of forwarders do not seal at all, so an absent chain tells you nobody recorded the forward rather than that no forward happened.
ARC is also on its way out. The IETF's DMARC working group has adopted a draft, Reclassifying ARC as Historic, which tells implementers not to rely on ARC going forward and to stop deploying it, and tells anyone still processing the headers not to make delivery decisions on chain validity alone. Historic status does not switch anything off: RFC 8617 stands, deployed code keeps running, and Microsoft's header documentation still described the ARC fields and reason 130 at its August 2026 revision. The successor work is DKIM2, still at draft stage. So you will keep meeting these headers for years, and the way to read them has not changed: a chain tells you what an intermediary says it saw. Weigh who sealed it, not the fact that a seal validated.
What about Microsoft's compauth verdict?
Exchange Online writes its own composite verdict alongside the standard one, and it is the line to read when a message lands in a Microsoft 365 mailbox:
Authentication-Results: spf=fail (sender IP is 203.0.113.42)
smtp.mailfrom=vps-host-77.example.net; dkim=none;
dmarc=fail action=none header.from=northwind-traders.example;
compauth=fail reason=001
compauth=fail is an authentication result rather than a delivery outcome: Microsoft's own documentation says the message may still be allowed if nothing else about it looks suspicious, so read the code as a verdict on the sender's posture and go to message trace for what actually happened to the mail. dmarc= here also has a fourth value the RFC does not: bestguesspass means the domain publishes no DMARC record, and the message would have passed if it did.
The three-digit reason code is the payload. Microsoft publishes the whole table, around thirty codes in families, and we have decoded every one of them with what each means for the message. These are the ones worth recognising on sight:
000, failed explicit authentication. The message failed DMARC and the domain's policy isp=quarantineorp=reject. The domain owner asked receivers to treat this as a failure and Microsoft recorded that it was one.001, failed implicit authentication. The sending domain published no authentication records, or published weak ones: SPF ending~allor?all, or DMARC atp=none. That is the code our example earns, because the spoofed domain sits atp=none.010, someone spoofing your own domain. DMARC failed, the policy is reject or quarantine, and the From domain is one of your organisation's accepted domains. Staff being impersonated to staff.601, the same shape without the enforcing policy. The From domain is one of your accepted domains and the message failed the weaker implicit check. It sits in6xx, a second failure family that catches people out because it is nowhere near000.1xx, passed, and four of them are worth recognising because the message underneath still looks broken.100is SPF or DKIM passing in alignment.109is a domain with no DMARC record whose message would have passed anyway.115is mail from a tenant where the From domain is an accepted domain.108is DKIM failing because an earlier hop Microsoft trusts modified the body, typically your own on-premises Exchange, which makes it a plumbing job rather than an attack.130is a trusted ARC sealer's result overriding a DMARC failure, and only sealers you have explicitly configured count.
Our take: the code to worry about is 001, not 000. A 000 means the domain owner published an enforcing policy and Microsoft evaluated the message against it, which is the system working. A 001 on mail claiming to be from a domain you own says your published records are too weak for Microsoft to defend you with, and the fix is a DMARC record with teeth rather than anything in the message. Check the legitimate senders using that domain before you tighten it.
Microsoft's inbound handling has its own set of rules and its own vocabulary of rejections, and the M365 blocking post unpacks the ones you meet on the sending side.
What if everything passes and the message is still fraudulent?
Then it probably is not a spoof, and the headers have just told you something more serious.
Three different things arrive looking like the same email, and the authentication result is what separates them.
- A spoof. The attacker writes your supplier's name in
From:and sends from their own infrastructure. Nothing aligns, DMARC fails, and everything above applies. This is our example. - Compromised sending infrastructure. The attacker controls a service or an SMTP credential the supplier authorised: a web application, a mail plugin, a submission account. SPF passes, because that host really is allowed to send for the domain, and DKIM may sign correctly too. The authentication is honest; the thing it is vouching for is not.
- A compromised mailbox. The attacker is logged into the supplier's actual account and sending from it. SPF passes, DKIM signs with the supplier's real key, DMARC aligns, and the analyser is right to return a clean verdict. This is business email compromise, and at the transport layer nothing is wrong at all.
Headers separate the first from the other two cleanly. Separating the second from the third is where the origin evidence earns its keep.
What the headers still give you is the origin. Compare the source IP, the Received path and the sending client fingerprint against a message from the same person last month. A supplier whose mail has come from the same M365 tenant for two years, suddenly arriving via a residential IP in another country, earns a phone call, on a number you already have rather than the one in the signature.
Authentication proves identity. It has never proved intent, which is the same gap that makes mail land in spam despite passing every check, read from the other direction.
What to do:
- Keep the raw headers of anything suspicious before the mailbox owner deletes the message. Exchange Online message trace will not reconstruct them for you.
- Read
Authentication-Results,Return-PathandFrom:together rather than one at a time. The gap between them is the finding. - Treat every
Received:header below your own gateway as a claim rather than a record, and take the originating IP to a PTR check instead of trusting the hostnames in the chain. - On a Microsoft 365 mailbox, read the
compauthreason code as well as the DMARC verdict.001on a domain you own is your own posture failing, not the sender's. - Check who wrote the
Authentication-Resultsyou are reading. Your own gateway's verdict is evidence; one that arrived with the message is a claim. - If a DKIM signature exists, read
d=ands=off theDKIM-Signatureheader rather than trusting the summary. A message can carry several signatures, and DMARC passes if any one of them verifies in alignment, so do not stop at the first. - Where a forwarder or mailing list is involved, look for
ARC-Sealand readcv=on the highest instance before you blame the sender for a broken signature. - Never verify new banking details using anything in the suspicious message. Use the number in your vendor master or a contact you have spoken to before, and make somebody other than the person who received the email approve the change. Headers can tell you a message is suspicious. They cannot tell you an account number is real.
- When the answer is "the supplier's domain is at
p=none, so nobody stopped this", check your own DMARC policy before you finish the email saying so. Publish a record, read the aggregate reports, and get to enforcement before somebody writes your name on an invoice.