Skip to content

Instantly share code, notes, and snippets.

@stormwild
Created March 3, 2026 16:17
Show Gist options
  • Select an option

  • Save stormwild/ffb3410002fe32280a34b27e712e7f74 to your computer and use it in GitHub Desktop.

Select an option

Save stormwild/ffb3410002fe32280a34b27e712e7f74 to your computer and use it in GitHub Desktop.

Tamper-Evident Reply Validation for Outbound Email

Problem Statement

When sending outbound emails from an enterprise system — particularly in regulated domains like claims management and third-party administration (TPA) — how do you embed a security feature so that when a client replies, you can prove the inbound email is a legitimate reply to that exact outbound message and not a spoofed or forged message?

This is especially critical in workflows where a reply could trigger financial actions such as claim approvals or payment instructions.


Overview

There is no built-in Microsoft Graph API feature for reply authentication. Graph provides useful threading primitives (conversationId, In-Reply-To headers, internetMessageId) but none of these are tamper-evident — they help with correlation, not cryptographic proof.

This document describes a layered approach that combines:

  1. HMAC-signed reply tokens — cryptographically binding each outbound email to a verifiable token
  2. Multi-layer token embedding — ensuring the token survives the reply chain despite email client stripping
  3. Tiered confidence scoring — assessing inbound replies based on which verification signals are present

Key Constraint: Custom Headers Do Not Survive Replies

Microsoft Graph allows setting custom x- headers via internetMessageHeaders when creating a message. However, these headers are stripped when the recipient replies or forwards. Extended properties (singleValueExtendedProperties) are also lost on reply/forward from the recipient's mailbox.

This means x-reply-token on your outbound message will not appear on the inbound reply. The architecture must account for this.


Architecture

Token Generation

When sending an outbound email, compute an HMAC over immutable message properties and embed the resulting token in multiple places so it survives forwarding and client stripping.

public sealed class ReplyTokenSigner
{
    private readonly byte[] _signingKey; // from AWS Secrets Manager or similar

    /// <summary>
    /// Generates a token that cryptographically binds together the message reference,
    /// recipient, and claim — none of which an attacker can reconstruct without the key.
    /// </summary>
    public string GenerateReplyToken(
        string messageRef,
        string recipientEmail,
        string claimId)
    {
        var payload = $"{messageRef}|{recipientEmail.ToLowerInvariant()}|{claimId}";

        using var hmac = new HMACSHA256(_signingKey);
        var hash = hmac.ComputeHash(Encoding.UTF8.GetBytes(payload));
        var signature = Base64Url.EncodeToString(hash[..16]); // truncate to 128-bit

        // Format: "messageRef.hmacSignature"
        return $"{messageRef}.{signature}";
    }
}

Token Embedding Layers

No single embedding mechanism survives every email client, so we layer three approaches:

Layer A — Plus-Addressed Reply-To (Strongest)

The token rides inside the reply-to address itself. When the recipient hits "Reply", their email client sends to this address — carrying the token as the To on the inbound message.

Reply-To: claims-inbound+a1b2c3d4e5f6.xYz9QrWpLm@yourdomain.com

Your inbound mail routing (Exchange rules, Graph subscription, or SES) delivers anything matching claims-inbound+*@yourdomain.com to your processing pipeline.

Layer B — Custom MIME Headers (Outbound Record Only)

Set x- headers on the outbound message for your own reference. These are readable on the original sent message via Graph's $select=internetMessageHeaders but will not be present on the reply.

{
  "internetMessageHeaders": [
    { "name": "x-reply-token", "value": "a1b2c3d4e5f6.xYz9QrWpLm" },
    { "name": "x-claim-ref", "value": "CLM-2024-00451" }
  ]
}

Layer C — Hidden HTML Body Token (Survives in Quoted Content)

Most email clients include the original message body when replying. A hidden element embedded in the HTML body will appear in the quoted section of the reply.

<!-- msg-ref:a1b2c3d4e5f6.xYz9QrWpLm -->
<div style="display:none;font-size:0;line-height:0;max-height:0;
            overflow:hidden;color:transparent"
     data-msg-ref="a1b2c3d4e5f6.xYz9QrWpLm">&#8203;</div>

Sending via Microsoft Graph

Outbound Email Service

public sealed class GraphOutboundEmailService
{
    private readonly GraphServiceClient _graphClient;
    private readonly ReplyTokenSigner _signer;
    private readonly IOutboundEmailRepository _repo;

    public async Task<string> SendSignedEmailAsync(
        string fromUserId,        // user ID or shared mailbox
        string recipientEmail,
        string subject,
        string htmlBody,
        string claimId)
    {
        // 1. Generate a unique message reference and HMAC token
        var messageRef = Guid.NewGuid().ToString("N")[..12];
        var token = _signer.GenerateReplyToken(
            messageRef, recipientEmail, claimId);

        // 2. Build the reply-to with plus-addressing
        var replyToAddress = $"claims-inbound+{token}@yourdomain.com";

        // 3. Embed token in HTML body
        var signedBody = InjectBodyToken(htmlBody, token);

        // 4. Build the Graph message
        var message = new Message
        {
            Subject = subject,
            Body = new ItemBody
            {
                ContentType = BodyType.Html,
                Content = signedBody
            },
            ToRecipients = new List<Recipient>
            {
                new() { EmailAddress = new EmailAddress
                    { Address = recipientEmail } }
            },
            ReplyTo = new List<Recipient>
            {
                new() { EmailAddress = new EmailAddress
                    { Address = replyToAddress,
                      Name = "Claims Department" } }
            },
            InternetMessageHeaders = new List<InternetMessageHeader>
            {
                new() { Name = "x-claim-ref", Value = claimId },
                new() { Name = "x-reply-token", Value = token }
            }
        };

        // 5. Send via Graph
        await _graphClient.Users[fromUserId]
            .SendMail
            .PostAsync(new SendMailPostRequestBody
            {
                Message = message,
                SaveToSentItems = true
            });

        // 6. Retrieve the sent message to capture Exchange-assigned IDs
        var sentMessage = await GetSentMessageAsync(fromUserId, messageRef);

        // 7. Persist the outbound record for later verification
        await _repo.SaveAsync(new OutboundEmailRecord
        {
            MessageRef = messageRef,
            Token = token,
            ClaimId = claimId,
            RecipientEmail = recipientEmail,
            InternetMessageId = sentMessage?.InternetMessageId,
            ConversationId = sentMessage?.ConversationId,
            SentAtUtc = DateTimeOffset.UtcNow,
            ExpiresAtUtc = DateTimeOffset.UtcNow.AddDays(90)
        });

        return messageRef;
    }

    private static string InjectBodyToken(string html, string token)
    {
        var hiddenToken = $"""
            <div style="display:none;font-size:0;line-height:0;
                        max-height:0;overflow:hidden;color:transparent"
                 data-msg-ref="{token}">&#8203;</div>
            """;

        var comment = $"<!-- msg-ref:{token} -->";

        if (html.Contains("</body>", StringComparison.OrdinalIgnoreCase))
            return html.Replace("</body>", $"{hiddenToken}{comment}</body>",
                StringComparison.OrdinalIgnoreCase);

        return html + hiddenToken + comment;
    }

    private async Task<Message?> GetSentMessageAsync(
        string userId, string messageRef)
    {
        // Query Sent Items for the message we just sent
        // Filter by the custom header or subject to locate it
        var messages = await _graphClient.Users[userId]
            .MailFolders["SentItems"]
            .Messages
            .GetAsync(config =>
            {
                config.QueryParameters.Top = 1;
                config.QueryParameters.Orderby = new[] { "sentDateTime desc" };
                config.QueryParameters.Select = new[]
                {
                    "id", "internetMessageId", "conversationId",
                    "internetMessageHeaders"
                };
            });

        return messages?.Value?.FirstOrDefault();
    }
}

Inbound Validation

Processing Inbound Replies via Graph

Set up a Graph change notification subscription on the shared mailbox to detect new messages, then validate each inbound reply.

public sealed class InboundReplyValidationService
{
    private readonly ReplyTokenSigner _signer;
    private readonly IOutboundEmailRepository _repo;
    private readonly GraphServiceClient _graphClient;

    public async Task<ReplyValidationResult> ValidateInboundReplyAsync(
        string mailboxUserId, string inboundMessageId)
    {
        // Fetch the full message with headers and body
        var message = await _graphClient
            .Users[mailboxUserId]
            .Messages[inboundMessageId]
            .GetAsync(config =>
            {
                config.QueryParameters.Select = new[]
                {
                    "id", "from", "subject", "body",
                    "conversationId", "internetMessageId",
                    "internetMessageHeaders", "receivedDateTime",
                    "toRecipients", "ccRecipients"
                };
            });

        // LAYER 1: Extract token from plus-addressed To/CC
        var token = ExtractTokenFromRecipients(message);

        // LAYER 2: Parse the quoted HTML body for the hidden token
        token ??= ExtractTokenFromBody(message?.Body?.Content);

        // LAYER 3: Check In-Reply-To header for correlation
        var inReplyTo = message?.InternetMessageHeaders?
            .FirstOrDefault(h =>
                h.Name.Equals("In-Reply-To", StringComparison.OrdinalIgnoreCase))
            ?.Value;

        if (token is null && inReplyTo is null)
            return ReplyValidationResult.NoTokenFound();

        // Look up the original outbound message
        OutboundEmailRecord? original = null;

        if (token is not null)
        {
            var messageRef = token.Split('.')[0];
            original = await _repo.GetByMessageRefAsync(messageRef);
        }

        // Fallback: correlate via In-Reply-To header
        original ??= inReplyTo is not null
            ? await _repo.GetByInternetMessageIdAsync(inReplyTo)
            : null;

        // Secondary fallback: conversationId grouping
        original ??= message?.ConversationId is not null
            ? await _repo.GetByConversationIdAsync(message.ConversationId)
            : null;

        if (original is null)
            return ReplyValidationResult.OriginalNotFound();

        if (original.ExpiresAtUtc < DateTimeOffset.UtcNow)
            return ReplyValidationResult.Expired(original);

        // Cryptographic verification if we have the token
        if (token is not null)
        {
            var expectedToken = _signer.GenerateReplyToken(
                original.MessageRef,
                original.RecipientEmail,
                original.ClaimId);

            if (!CryptographicOperations.FixedTimeEquals(
                    Encoding.UTF8.GetBytes(token),
                    Encoding.UTF8.GetBytes(expectedToken)))
            {
                return ReplyValidationResult.SignatureMismatch(original);
            }

            return ReplyValidationResult.Verified(original,
                confidence: ValidationConfidence.High);
        }

        // Correlation-only match (no cryptographic proof)
        return ReplyValidationResult.Correlated(original,
            confidence: ValidationConfidence.Medium,
            matchedVia: inReplyTo is not null
                ? "In-Reply-To" : "ConversationId");
    }

    private static string? ExtractTokenFromRecipients(Message? message)
    {
        if (message is null) return null;

        var allRecipients = (message.ToRecipients ?? Enumerable.Empty<Recipient>())
            .Concat(message.CcRecipients ?? Enumerable.Empty<Recipient>());

        foreach (var r in allRecipients)
        {
            var addr = r.EmailAddress?.Address;
            if (addr is null) continue;

            var match = Regex.Match(addr,
                @"claims-inbound\+([a-zA-Z0-9._-]+)@yourdomain\.com",
                RegexOptions.IgnoreCase);

            if (match.Success)
                return match.Groups[1].Value;
        }
        return null;
    }

    private static string? ExtractTokenFromBody(string? html)
    {
        if (html is null) return null;

        // Try HTML comment
        var commentMatch = Regex.Match(html, @"msg-ref:([a-zA-Z0-9._-]+)");
        if (commentMatch.Success)
            return commentMatch.Groups[1].Value;

        // Try data attribute
        var attrMatch = Regex.Match(html,
            @"data-msg-ref=""([a-zA-Z0-9._-]+)""");
        if (attrMatch.Success)
            return attrMatch.Groups[1].Value;

        return null;
    }
}

Supporting Types

public sealed record OutboundEmailRecord
{
    public string MessageRef { get; init; } = default!;
    public string Token { get; init; } = default!;
    public string ClaimId { get; init; } = default!;
    public string RecipientEmail { get; init; } = default!;
    public string? InternetMessageId { get; init; }
    public string? ConversationId { get; init; }
    public DateTimeOffset SentAtUtc { get; init; }
    public DateTimeOffset ExpiresAtUtc { get; init; }
    public string? SigningKeyId { get; init; }
}

public enum ValidationConfidence
{
    None,
    Low,
    Medium,
    High
}

public sealed record ReplyValidationResult
{
    public bool IsValid { get; init; }
    public ValidationConfidence Confidence { get; init; }
    public string? MatchedVia { get; init; }
    public string? FailureReason { get; init; }
    public OutboundEmailRecord? OriginalMessage { get; init; }

    public static ReplyValidationResult Verified(
        OutboundEmailRecord original,
        ValidationConfidence confidence) =>
        new()
        {
            IsValid = true,
            Confidence = confidence,
            OriginalMessage = original,
            MatchedVia = "HMAC Token"
        };

    public static ReplyValidationResult Correlated(
        OutboundEmailRecord original,
        ValidationConfidence confidence,
        string matchedVia) =>
        new()
        {
            IsValid = true,
            Confidence = confidence,
            OriginalMessage = original,
            MatchedVia = matchedVia
        };

    public static ReplyValidationResult NoTokenFound() =>
        new() { IsValid = false, FailureReason = "No verification token found" };

    public static ReplyValidationResult OriginalNotFound() =>
        new() { IsValid = false, FailureReason = "Original outbound message not found" };

    public static ReplyValidationResult SignatureMismatch(
        OutboundEmailRecord original) =>
        new()
        {
            IsValid = false,
            FailureReason = "HMAC signature mismatch — possible spoofing",
            OriginalMessage = original
        };

    public static ReplyValidationResult Expired(
        OutboundEmailRecord original) =>
        new()
        {
            IsValid = false,
            FailureReason = "Reply token has expired",
            OriginalMessage = original
        };
}

Confidence Tiers

Since no single signal is 100% reliable across all email clients, score each inbound reply:

Confidence Signals Present What It Proves
High HMAC token validates (from plus-address or body) Cryptographic proof this is a reply to your exact message
Medium In-Reply-To matches your stored internetMessageId RFC-standard threading — strong correlation but not tamper-evident
Low Only conversationId matches Exchange grouping — could be a manually composed message added to the thread
None No signals match Likely spoofed or unrelated — route to manual review queue

What Survives Replies in Microsoft Graph

Signal Set via Graph? Survives Reply? Tamper-Evident?
x- custom headers Yes (internetMessageHeaders) No N/A
singleValueExtendedProperties Yes No N/A
conversationId Auto-assigned by Exchange Yes (same thread) No
In-Reply-To / References Auto-managed by Exchange Yes (RFC standard) No
Plus-addressed Reply-To Yes (replyTo property) Yes — becomes the To on reply Yes (carries HMAC)
Hidden HTML body token Yes (in body.content) Yes — in quoted original Yes (carries HMAC)

Operational Considerations

Key Rotation

Store a signingKeyId alongside the token so you can rotate signing keys without invalidating in-flight replies. Keep old keys available for the validity window (e.g. 90 days). If using AWS, store keys in Secrets Manager with versioning.

Token format with key ID: "k1.a1b2c3d4e5f6.xYz9QrWpLm"
                           ^-- key version

Database Schema

Persist every outbound email's metadata in your database. This is the source of truth for token recomputation and correlation.

CREATE TABLE outbound_emails (
    id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    message_ref     VARCHAR(12) NOT NULL UNIQUE,
    token           VARCHAR(128) NOT NULL,
    claim_id        VARCHAR(64) NOT NULL,
    recipient_email VARCHAR(256) NOT NULL,

    -- Exchange-assigned identifiers (captured after send)
    internet_message_id VARCHAR(512),
    conversation_id     VARCHAR(256),

    -- Signing metadata
    signing_key_id  VARCHAR(32),

    -- Timestamps
    sent_at_utc     TIMESTAMPTZ NOT NULL,
    expires_at_utc  TIMESTAMPTZ NOT NULL,
    created_at_utc  TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE INDEX ix_outbound_emails_message_ref
    ON outbound_emails (message_ref);

CREATE INDEX ix_outbound_emails_internet_message_id
    ON outbound_emails (internet_message_id);

CREATE INDEX ix_outbound_emails_conversation_id
    ON outbound_emails (conversation_id);

CREATE INDEX ix_outbound_emails_claim_id
    ON outbound_emails (claim_id);

Failure Handling

When no valid token is found on an inbound message, do not silently accept it. Route it to a manual review queue or respond with a challenge asking the sender to reply from the original thread. In a claims context, this prevents spoofed "approved" replies from triggering payments.

Complementary to DMARC/DKIM/SPF

This mechanism is complementary to standard email authentication:

  • DMARC/DKIM/SPF tell you the sending domain is legitimate
  • Reply tokens tell you the reply is to a specific message you actually sent

Both together give you strong anti-spoofing coverage. DMARC prevents domain impersonation; reply tokens prevent message impersonation within a legitimate domain.

Graph Subscription for Inbound Monitoring

Set up a change notification subscription on the shared mailbox to trigger your validation pipeline automatically when new messages arrive:

POST /subscriptions
{
    "changeType": "created",
    "notificationUrl": "https://your-api.example.com/webhooks/graph-mail",
    "resource": "users/{shared-mailbox-id}/mailFolders('Inbox')/messages",
    "expirationDateTime": "2025-01-01T00:00:00Z",
    "clientState": "your-secret-state-value"
}

Technology Context

This solution is designed for the following stack:

  • .NET 8/9 minimal APIs with FastEndpoints and Vertical Slice Architecture
  • Microsoft Graph SDK v5 (Microsoft.Graph NuGet package)
  • Auth0 + Azure Entra for identity, with M2M credentials for API-to-API auth
  • Aurora PostgreSQL on AWS RDS with daily password rotation and IAM authentication
  • AWS EKS for container orchestration with ECR for image registry
  • Azure DevOps for CI/CD pipelines

References

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment