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.
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:
- HMAC-signed reply tokens — cryptographically binding each outbound email to a verifiable token
- Multi-layer token embedding — ensuring the token survives the reply chain despite email client stripping
- Tiered confidence scoring — assessing inbound replies based on which verification signals are present
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.
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}";
}
}No single embedding mechanism survives every email client, so we layer three approaches:
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.
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">​</div>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}">​</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();
}
}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;
}
}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
};
}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 |
| 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) |
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
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);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.
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.
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"
}
This solution is designed for the following stack:
- .NET 8/9 minimal APIs with FastEndpoints and Vertical Slice Architecture
- Microsoft Graph SDK v5 (
Microsoft.GraphNuGet 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