Your Email Threading Anchor Is Forgeable. Sign It.

The fix for SES rewriting your Message-ID has a hole in it that nobody talks about — and the payload is another customer's conversation.

If you send transactional mail through Amazon SES, you already know it replaces your Message-ID with its own. The fix everyone reaches for is to inject your own identifier into the References header before sending — something like <[email protected]> — and match on it when the reply comes back. We covered why the rewrite happens and how to build threading in layers in How to Receive Inbound Email With Amazon SES.

This post is about the part that advice usually leaves out.

That anchor is a sequential integer in a header the sender controls. Anyone who has ever received one of your emails can read it, guess every other value, and put one in their own outgoing message. Your matcher will do exactly what you built it to do: thread their email into someone else's ticket.

The bug is not that threading breaks. It is that threading works, for the wrong person.

The Exploit Chain

Walk it through, because the damage is one step further than it first appears.

  1. A customer emails support and gets a reply. Their copy carries References: <[email protected]>. Nothing here is secret — it is a plain header, visible in "show original" in any mail client.
  2. They send a fresh email to your support address with References: <[email protected]> set by hand.
  3. Your matcher extracts the candidate ids, finds ticket 43, and appends their message to it. They now have a message inside a ticket that belongs to someone else.
  4. An agent opens ticket 43 and replies.

Step 4 is where it turns into a disclosure. If your reply defaults its recipient to the sender of the most recent inbound message — a common and otherwise sensible default, and the one we shipped — that recipient is now the attacker. Add the quoted thread that replies usually carry, and the agent, doing something completely ordinary, sends the entire prior conversation to a stranger.

Check your own reply path before assuming you are unaffected. The question to answer is: when an agent hits reply, where does the To: address come from — the ticket's contact, or the last message in it? If it is the last message, this chain is live for you.

Enumeration makes it worse. Ticket ids are sequential, so an attacker does not have to guess — they can walk the range. And because a legitimate anchor is delivered to every recipient of every outbound email, the starting point is free.

Sign the Id

The anchor has to be unguessable, but it also has to survive a round trip through mail clients you do not control, so it cannot be state you look up — it has to carry its own proof.

An HMAC does exactly this. Keep the readable id, append a truncated signature over it:

<[email protected]>

Building it:

protected const SIGNATURE_LENGTH = 16;

public static function build(string $type, int $id): string
{
    return sprintf('<%s-%d-%s@%s>', $type, $id, self::sign($type, $id), self::DOMAIN);
}

protected static function sign(string $type, int $id): string
{
    return substr(
        hash_hmac('sha256', $type.':'.$id, (string) config('app.key')),
        0,
        self::SIGNATURE_LENGTH,
    );
}

Three things are worth pointing out.

The signature covers the type as well as the id. Signing "ticket:42" rather than "42" means a valid ticket anchor cannot be replayed as a conversation anchor. If you have more than one kind of threadable object — tickets, conversations, orders — signing the bare id lets an attacker move a legitimate signature between namespaces, and both lookups will accept it.

16 hex characters is 64 bits, and that is enough here. The usual instinct is to keep the full 256-bit digest, but the constraint is header length across mail clients, and the threat model is online guessing against your inbound endpoint — not an offline attack. An attacker gets no oracle and no feedback beyond "my message threaded or it didn't". 64 bits against that is ample; the truncation buys you an anchor that stays short enough to survive.

It is stateless. No table, no migration, no cleanup job. The signature is recomputed on verification and compared. That matters more than it sounds: a threading anchor that requires a database row is a threading anchor that stops working when the row is pruned, and pruning is exactly what happens to old tickets.

Verifying, and the Two Ways to Get It Wrong

public static function verify(string $type, string $candidate): ?int
{
    $candidate = trim($candidate, " \t<>");

    $pattern = '/^'.preg_quote($type, '/').'-(\d+)-([0-9a-f]{'.self::SIGNATURE_LENGTH.'})@'
        .preg_quote(self::DOMAIN, '/').'$/i';

    if (! preg_match($pattern, $candidate, $m)) {
        return null;
    }

    $id = (int) $m[1];

    if (! hash_equals(self::sign($type, $id), strtolower($m[2]))) {
        return null;
    }

    return $id;
}

Use hash_equals, not ===. A plain string comparison returns early on the first differing byte, and the time it takes leaks how much of the signature was right. That is a real attack against a remote verifier, and the fix costs nothing.

Reject unsigned anchors outright. This is the one people get wrong, because it feels harsh. If you shipped the unsigned format first, you have legitimate old emails in the wild carrying <[email protected]>, and it is tempting to keep accepting them "just for those". Don't. An accept-both matcher is an unsigned matcher — an attacker simply omits the signature. Log the rejection instead, so you can see whether anyone is actually hitting it.

In practice the fallout is small and self-correcting: a customer whose old email fails to thread gets a new ticket rather than a silent leak, and every email you send after the change carries a signed anchor.

A Valid Signature Is Not Authorization

This is the step that gets skipped once the crypto is in place. A verified signature proves the id was issued by you. It proves nothing about who is sending the email now.

Someone forwarded a support email to a colleague; the colleague replies. The anchor is genuine. It should still not thread into the ticket unless the lookup is scoped:

foreach ($raw as $id) {
    $anchorId = ThreadAnchor::verify('ticket', $id);
    if ($anchorId !== null && Ticket::where('id', $anchorId)
            ->where('workspace_id', $workspaceId)->exists()) {
        return $anchorId;
    }
}

Scope every layer, not just this one. If you run both shared and personal inboxes, scope the owner too — otherwise mail to a shared address can thread into someone's private conversation, which is the same disclosure in a different costume.

The Other Layers Have Their Own Version of This Bug

The signed anchor is one layer. The fallbacks you build around it are worth auditing for the same class of mistake, because a strict layer sitting in front of a loose one buys you nothing.

Prefix-matching the provider's message id. SES message ids vary by region in the domain part, so full-string equality misses. The tempting fix is a LIKE on the id portion — but real SES ids share a long, slowly-changing leading prefix (0100 plus an epoch in hex), so a short prefix matches nearly every message you have ever sent. Set a floor. Thirty characters keeps genuine ids matching while rejecting truncated or crafted candidates.

The subject-and-sender heuristic. Every threading implementation eventually grows one, and it is the loosest layer by construction. Two guards keep it honest. Only run it when the mail actually looks like a reply — it carried threading headers that failed to resolve, or the subject has a Re:/Fwd: prefix — so a brand-new email can never be absorbed into an existing thread. And bound it by recency; seven days is generous.

if (! $this->looksLikeReply($raw, $subject)) {
    return null;
}

Normalizing the subject needs a loop rather than a single pass, incidentally — mail clients stack prefixes, and Re: Fwd: Re: Ticket #42 - Login problem has to reduce all the way down to login problem before it can match anything.

The Short Version

Would rather not own this? GoPimi runs the inbound pipeline as a product — signed threading anchors, layered matching, and per-workspace scoping are how it works by default. See the Email Pipeline guide, or read Email to Ticket Automation for what happens to a message after it arrives.