How to Read Received Email Headers Without Trusting Every Hop
Read Received headers in two passes. First, move from the top downward until you reach the last line written by a mail server you trust. That line records the connection at your trust boundary. Then read the trusted lines from the bottom upward to reconstruct the route toward the mailbox. Treat everything below the boundary as an upstream claim, not proof.
SMTP servers prepend these fields, so the newest trusted hop appears first. The common advice to “read from the bottom up” is incomplete because a sender can supply convincing-looking lines before your receiver adds its own.
Start with the trace, not the visible From address
Here is a hypothetical, shortened header chain. The domains and IP addresses are reserved examples:
Received: from inbound.receiver.example (inbound.receiver.example [192.0.2.44])
by mailbox.receiver.example with ESMTPS id final-42
for <alex@receiver.example>; Sun, 04 Oct 2026 03:50:12 +0000
Received: from outbound.sender.example (outbound.sender.example [198.51.100.27])
by inbound.receiver.example with ESMTPS id edge-19;
Sun, 04 Oct 2026 03:50:08 +0000
Received: from laptop.invalid (unknown [203.0.113.9])
by outbound.sender.example with ESMTP id origin-7;
Sun, 04 Oct 2026 03:49:20 +0000
From: "Example Sender" <sender@example.net>
To: alex@receiver.example
RFC 5321 section 4.4 requires each receiving SMTP server to add its trace information at the beginning of the message. Existing Received lines must keep their order. That is why the first line above is the newest hop and the last line is the oldest visible claim.
The visible From field is not part of this route. It identifies the author presented to the reader. The From, Reply-To, and Return-Path guide owns those address roles; this article owns the transport trace.
Where does the trustworthy route begin?
Assume mailbox.receiver.example and inbound.receiver.example are systems operated by, or explicitly trusted by, the receiving organization.
The top line is trusted because the by host is the mailbox system. The second line is also trusted because its by host is the receiver’s inbound server. That second line says the inbound server observed a connection from 198.51.100.27, presenting the name outbound.sender.example.
The third line falls outside that receiver boundary. It may be accurate, but it was already inside the message when the receiver accepted it. Do not use it alone to identify a person, device, or geographic origin.
This boundary is the same reason an Authentication-Results line needs a trusted producer. RFC 8601 says the presence of an authentication result does not make its assertions valid by itself. Trust depends on who added it and how it reached the consumer.
But can’t a sender forge a Received line?
Yes, below your boundary. A sender can place text that looks like a Received field in the submitted header block. Your border server then prepends a real line above it. That fake lower line does not overwrite the border evidence, but it can mislead anyone who starts at the bottom and trusts every row.
So the safe question is not “Does this header look valid?” It is “Which known system says it observed this connection?”
What do from, by, with, id, for, and the date mean?
from describes the connecting side. In an SMTP environment, RFC 5321 says it should contain the name presented in EHLO plus an address literal derived from the TCP connection. The name is a claim from the connecting client. The connection IP is the stronger observation made by the receiver.
by names the server that wrote the line. This is the field I use to decide whether a row belongs inside the configured trust boundary.
with records the transport variant, such as ESMTP or ESMTPS. It can show that one hop used TLS, but it does not prove end-to-end encryption or sender identity.
id is the receiving system’s local queue or transaction identifier. It is useful when correlating a message with logs from that same system.
for may show one envelope recipient. It is optional and may be omitted for privacy or implementation reasons. Its absence is not evidence that the message lacked a recipient.
The date follows the last semicolon. RFC 5322 defines the trace-field shape, but real fields contain folding, comments, and implementation-specific tokens. Parse defensively and preserve the raw line.
Why timestamps are clues, not a stopwatch
In the hypothetical chain, the trusted timestamps are four seconds apart. That looks like transit time, but independent server clocks may disagree. An untrusted upstream timestamp can also be invented.
Use time differences to find a place worth investigating, then confirm the delay with queue logs from the systems you control. RFC 5321 recommends explicit numeric timezone offsets because they make comparison easier. It does not turn every clock into authoritative evidence.
A large or negative interval can mean queueing, clock skew, a malformed field, or a fabricated upstream row. Keep those states separate.
Mark the trust boundary before you automate the trace
The executed Node.js helper below unfolds continued lines, extracts common clauses, and marks only rows whose by host is in a configured receiver set. It deliberately stops extending trust after the first unknown receiver:
function unfold(raw) {
return raw.replace(/\r?\n[ \t]+/g, ' ')
}
function parseReceived(raw) {
return unfold(raw)
.split(/\r?\n/)
.filter((line) => /^Received:/i.test(line))
.map((line) => {
const value = line.slice(line.indexOf(':') + 1).trim()
const semicolon = value.lastIndexOf(';')
const route = semicolon >= 0 ? value.slice(0, semicolon) : value
return {
from: route.match(/\bfrom\s+([^\s(]+)/i)?.[1] ?? null,
by: route.match(/\bby\s+([^\s(]+)/i)?.[1] ?? null,
protocol: route.match(/\bwith\s+([^\s;]+)/i)?.[1] ?? null,
id: route.match(/\bid\s+([^\s;]+)/i)?.[1] ?? null,
recipient: route.match(/\bfor\s+<?([^>\s;]+)>?/i)?.[1] ?? null,
dateText: semicolon >= 0 ? value.slice(semicolon + 1).trim() : null,
raw: line,
}
})
}
export function markTrust(raw, trustedReceivers) {
let insideBoundary = true
return parseReceived(raw).map((hop) => {
const trusted =
insideBoundary && trustedReceivers.has(hop.by?.toLowerCase())
if (!trusted) insideBoundary = false
return {
...hop,
trust: trusted ? 'receiver-trusted' : 'upstream-claim',
}
})
}
The parser keeps from, by, protocol, queue ID, recipient, timestamp text, and the original line. I ran it against the three-hop fixture above. Five checks passed: line unfolding, hop count, the two trusted rows, the untrusted upstream row, and extraction of the border source.
The result is a trust marker, not a universal forensic parser. Production systems should use a maintained message parser, retain the original message, and configure trusted receivers from real infrastructure ownership rather than hostname resemblance.
After locating the border connection, corroborate it. Check whether forward and reverse DNS agree, compare the connection IP with SPF evidence, inspect DKIM and DMARC alignment, and use logs from the receiving MTA. The SPF, DKIM, and DMARC guide explains why those checks answer different questions.
A Received chain can show where trusted infrastructure observed a message. It cannot prove who typed it, whether the content is safe, or whether every upstream hop is genuine. Stop at the boundary, preserve uncertainty below it, and investigate from evidence your own systems actually produced.