Webhooks allow one application to notify another when an event occurs. A payment provider can report a successful payment, GitHub can report a push, and an order management system can notify a warehouse when an order is ready.
The receiving application usually exposes a public HTTP endpoint for these notifications. That creates two security problems. An attacker might send a fake request that looks like a legitimate event, or capture a genuine request and send it again to repeat the operation.
Signature verification helps establish authenticity and detect payload tampering. Replay protection helps prevent the same signed event from being accepted repeatedly. Both are important when a webhook can trigger actions such as changing an order status, updating an account balance, or issuing a refund.
This article explains how to implement these checks in C# with ASP.NET Core.
Why Webhook Security Needs More Than HTTPS
HTTPS protects data while it travels between systems, but it does not prove that every request reaching your endpoint was created by the intended webhook provider. Your application needs to validate the request itself.
Consider a payment webhook that tells your application an invoice has been paid. If the endpoint trusts the JSON body without checking its authenticity, someone could submit a request with a fabricated payment status.
A second problem occurs when a genuine request is captured and resent. The signature may still be valid because the attacker has not changed the original payload. If your application processes every delivery as a new event, the same business operation could run more than once.
Security check | What it protects against | What it does not solve alone |
|---|
HTTPS | Network interception and modification in transit | Fake requests sent directly to the endpoint |
HMAC signature verification | Forged or modified signed content | Replaying an unchanged valid request |
Timestamp validation | Requests signed outside an acceptable time window | Replays within that window |
Event ID deduplication | Reprocessing a previously accepted event ID | Duplicate business effects if IDs are not stable or storage is not atomic |
Idempotent business logic | Repeated execution of the same logical operation | Forged events unless authentication is also checked |
A secure implementation combines these controls instead of relying on just one.
How HMAC Webhook Signatures Work
A common webhook design uses a shared secret and the HMAC-SHA256 algorithm.
The sender calculates a signature using the secret and the content it wants to protect. It sends that signature in an HTTP header along with the request. The receiver uses the same secret and the agreed signing format to calculate its own signature.
If the signatures match, the receiver has evidence that the signed content was created by someone who knows the secret and has not been modified since signing.
For example, a provider might send a request with these headers:
POST /webhooks/orders HTTP/1.1
Content-Type: application/json
X-Webhook-Timestamp: 1791964800
X-Webhook-Id: evt_8f42a1
X-Webhook-Signature: sha256=SIGNATURE_VALUE
In this illustrative protocol, the signature covers the timestamp, event ID, and raw request body. Including these values in the signed content prevents an attacker from changing the event ID or timestamp without invalidating the signature.
Important: This header format is an example of a custom webhook protocol, not a universal standard. Providers differ in their signing algorithms, header names, timestamp formats, and exact bytes to sign. For example, GitHub uses X-Hub-Signature-256 to sign the request body with HMAC-SHA256. Implement the format required by your actual provider rather than copying a custom format into an existing integration.
Verify the Signature Against the Raw Request Body
One of the most common implementation mistakes is parsing the JSON body and then serializing it again before calculating the signature.
These two JSON documents represent equivalent data, but their bytes differ:
{"orderId":42,"status":"paid"}
{
"orderId": 42,
"status": "paid"
}
Whitespace, property ordering, character encoding, and line endings can all affect the resulting HMAC. A signature must be calculated from the exact bytes covered by the sender's signing scheme.
The following helper implements the illustrative protocol described above. It signs the timestamp, event ID, and raw body in that order, with periods as separators.
using System.Globalization;
using System.Security.Cryptography;
using System.Text;
public static class WebhookSignatureVerifier
{
public static bool Verify(
byte[] rawBody,
string timestamp,
string eventId,
string signatureHeader,
string secret)
{
if (rawBody is null ||
string.IsNullOrWhiteSpace(timestamp) ||
string.IsNullOrWhiteSpace(eventId) ||
string.IsNullOrWhiteSpace(signatureHeader) ||
string.IsNullOrEmpty(secret))
{
return false;
}
if (!signatureHeader.StartsWith(
"sha256=",
StringComparison.Ordinal))
{
return false;
}
string suppliedHex = signatureHeader["sha256=".Length..];
byte[] suppliedSignature;
try
{
suppliedSignature = Convert.FromHexString(suppliedHex);
}
catch (FormatException)
{
return false;
}
if (suppliedSignature.Length != 32)
{
return false;
}
byte[] prefix = Encoding.UTF8.GetBytes(
$"{timestamp}.{eventId}.");
byte[] signedPayload = new byte[
prefix.Length + rawBody.Length];
Buffer.BlockCopy(
prefix, 0, signedPayload, 0, prefix.Length);
Buffer.BlockCopy(
rawBody, 0, signedPayload, prefix.Length,
rawBody.Length);
byte[] key = Encoding.UTF8.GetBytes(secret);
byte[] expectedSignature =
HMACSHA256.HashData(key, signedPayload);
return CryptographicOperations.FixedTimeEquals(
expectedSignature,
suppliedSignature);
}
}
The code uses CryptographicOperations.FixedTimeEquals rather than an ordinary string comparison. This avoids comparisons that stop at the first differing byte, reducing the risk of timing information leaking through signature checks.
The helper also rejects malformed hexadecimal signatures and signatures with an unexpected length before comparing them.
Keep the webhook secret outside source control. Load it from an appropriate secret store or protected configuration provider, and rotate it if it is exposed.
Validate the Timestamp to Limit Replay Attempts
A valid signature does not necessarily mean a request is fresh. An attacker who captures a genuine request may be able to replay it while its signature remains valid.
A signed timestamp provides a freshness check. The receiver compares the timestamp with its current UTC time and rejects requests outside an agreed window.
using System.Globalization;
public static class WebhookTimestampValidator
{
public static bool IsFresh(
string timestamp,
TimeSpan allowedDrift)
{
if (!long.TryParse(
timestamp,
NumberStyles.None,
CultureInfo.InvariantCulture,
out long unixSeconds))
{
return false;
}
DateTimeOffset requestTime;
try
{
requestTime =
DateTimeOffset.FromUnixTimeSeconds(unixSeconds);
}
catch (ArgumentOutOfRangeException)
{
return false;
}
TimeSpan difference =
DateTimeOffset.UtcNow - requestTime;
return difference.Duration() <= allowedDrift;
}
}
A five-minute tolerance is a reasonable starting point for many systems, but the correct window depends on the provider's delivery behavior and your operational requirements.
A timestamp check is not a replacement for event deduplication. An attacker may replay a request multiple times within the accepted window. A legitimate provider may also retry delivery within that period.
The timestamp must be covered by the signature. Otherwise, an attacker could simply replace an old timestamp with the current time.
Reject Duplicate Event IDs with Durable Storage
Timestamp validation limits the replay window, while an event ID lets the receiver recognize a delivery it has already accepted.
The receiver needs a store that can atomically claim an event ID. A process-local HashSet<string> is not enough for a production application because it disappears when the process restarts and is not shared by multiple application instances.
Define an abstraction for a shared replay store:
public interface IWebhookReplayStore
{
// Atomically records the ID if it has not already been seen.
// Returns false when the ID already exists.
Task<bool> TryRegisterAsync(
string eventId,
TimeSpan retention,
CancellationToken cancellationToken);
}
The implementation could use Redis with an atomic set-if-absent operation and an expiration, or a database table with a unique constraint. The important requirement is that two servers receiving the same event concurrently cannot both claim it successfully.
Retain event IDs long enough to cover the provider's retry and replay window. The retention period should reflect the provider's documented behavior and the risk of repeating the underlying operation.
The event ID must also be trustworthy. In the illustrative protocol, it is included in the signed content. If a provider does not sign its delivery ID, do not assume that header alone provides cryptographic replay protection.
Put the Checks Before Business Processing
The endpoint should read the body once, verify the signature, check freshness, and claim the event ID before trusting the payload.
The following ASP.NET Core minimal API example shows the order of operations. It assumes that IWebhookReplayStore is registered with a shared, atomic implementation and that the secret is supplied through configuration.
using System.Text.Json;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSingleton<
IWebhookReplayStore, RedisWebhookReplayStore>();
var app = builder.Build();
app.MapPost("/webhooks/orders", async (
HttpRequest request,
IConfiguration configuration,
IWebhookReplayStore replayStore,
CancellationToken cancellationToken) =>
{
const int maxBodyBytes = 1_000_000;
if (request.ContentLength is > maxBodyBytes)
{
return Results.StatusCode(
StatusCodes.Status413PayloadTooLarge);
}
await using var buffer = new MemoryStream();
var chunk = new byte[8192];
int totalBytes = 0;
while (true)
{
int read = await request.Body.ReadAsync(
chunk.AsMemory(), cancellationToken);
if (read == 0)
{
break;
}
totalBytes += read;
if (totalBytes > maxBodyBytes)
{
return Results.StatusCode(
StatusCodes.Status413PayloadTooLarge);
}
await buffer.WriteAsync(
chunk.AsMemory(0, read), cancellationToken);
}
byte[] rawBody = buffer.ToArray();
string? timestamp =
request.Headers["X-Webhook-Timestamp"].FirstOrDefault();
string? eventId =
request.Headers["X-Webhook-Id"].FirstOrDefault();
string? signature =
request.Headers["X-Webhook-Signature"].FirstOrDefault();
string? secret =
configuration["Webhooks:OrdersSecret"];
if (secret is null ||
timestamp is null ||
eventId is null ||
signature is null)
{
return Results.Unauthorized();
}
if (!WebhookSignatureVerifier.Verify(
rawBody, timestamp, eventId, signature, secret))
{
return Results.Unauthorized();
}
if (!WebhookTimestampValidator.IsFresh(
timestamp, TimeSpan.FromMinutes(5)))
{
return Results.Unauthorized();
}
bool firstDelivery = await replayStore.TryRegisterAsync(
eventId,
TimeSpan.FromDays(7),
cancellationToken);
if (!firstDelivery)
{
// A previously accepted delivery must not run twice.
return Results.Ok();
}
OrderWebhook? payload;
try
{
payload = JsonSerializer.Deserialize<OrderWebhook>(
rawBody);
}
catch (JsonException)
{
return Results.BadRequest();
}
if (payload is null)
{
return Results.BadRequest();
}
// Hand the verified event to a durable processing workflow.
// Do not perform irreversible business work before validation.
// A production implementation should persist the event and its
// processing status atomically before acknowledging delivery.
return Results.Accepted();
});
app.Run();
public sealed record OrderWebhook(
string OrderId,
string Status);
RedisWebhookReplayStore represents an application-specific implementation; it is not a built-in ASP.NET Core class. The OrderWebhook record is also an example payload and should match the sender's schema.
The body-size check protects the endpoint from unnecessarily large payloads. For stricter resource control, also configure server-level request limits and enforce content-type requirements.
There is an important reliability detail in this example: recording the event ID and then failing before the event is durably queued can cause a legitimate retry to be treated as a duplicate. In production, use a durable inbox pattern that stores the event ID, payload, and processing state in one transaction where possible. A worker can then process pending events and retry failures safely.
Make Webhook Processing Idempotent
Rejecting duplicate event IDs is useful, but it does not guarantee that a business operation runs only once.
For example, a webhook handler might update an order in a database and then fail before acknowledging the request. The sender retries the event. If the application has no durable record of the completed operation, it might apply the same change again.
Idempotency addresses this problem. An operation is idempotent when repeating it with the same logical input does not create additional effects.
For example, setting an order's status to Paid is usually safer than adding the order total to an account balance every time a payment event arrives.
For operations that must occur once logically, store the event ID or business idempotency key alongside the operation's result. Use a database transaction and a unique constraint where possible.
A reliable processing flow looks like this:
Verify the signature and timestamp.
Validate the event ID.
Store the event in a durable inbox, enforcing uniqueness on the event ID.
Return a successful HTTP response after durable acceptance.
Let a background worker process the event.
Record the outcome and retry temporary failures without repeating completed business effects.
This design also helps keep webhook endpoints responsive. The endpoint can acknowledge an event once it is safely stored rather than waiting for every downstream service to finish.
Common Mistakes to Avoid
Mistake | Why it causes problems | Better approach |
|---|
Verifying re-serialized JSON | The calculated signature may differ from the sender's signature | Verify the exact raw bytes |
Using ordinary string equality | It can expose timing differences during comparison | Use a constant-time comparison function |
Trusting the event ID header without checking the protocol | An unsigned ID may be changed independently of the payload | Deduplicate using an ID covered by the signature or another trusted mechanism |
Checking only the timestamp | A request can be replayed repeatedly inside the allowed window | Combine freshness checks with atomic event deduplication |
Keeping IDs in memory | Restarts and multiple instances can bypass the check | Use shared durable storage |
Recording an ID before safely storing the event | A crash can cause a valid retry to be discarded | Use a durable inbox and atomic persistence |
Returning success before storing the event | A crash can lose the delivery after the sender stops retrying | Acknowledge only after durable acceptance |
Logging secrets or complete sensitive payloads | Logs can expose credentials or customer data | Log event IDs, verification outcomes, and correlation IDs without secrets |
Testing the Webhook Security Flow
Test the security controls independently and then test them together. A successful request is only one part of the test suite.
Valid signature: The request passes verification when the secret and signed content are correct.
Modified payload: Changing one byte of the body causes verification to fail.
Missing signature: The endpoint rejects the request before processing the event.
Malformed signature: Invalid hexadecimal or an incorrect signature length is rejected.
Stale timestamp: A correctly signed request outside the permitted time window is rejected.
Duplicate event: Sending the same event ID twice does not create a second business operation.
Concurrent delivery: Two servers receiving the same event simultaneously cannot both claim it as new.
Storage failure: The application does not acknowledge an event that it has failed to persist reliably.
Processing retry: A temporary downstream failure can be retried without repeating an already completed business effect.
Also test the signature helper against the webhook provider's official test vectors. This is especially important when integrating with an existing service because its signature format may differ from the illustrative format used in this article.
Best Practices for Production Applications
Use a strong, randomly generated secret for each webhook integration. Store it in a secrets manager or protected configuration system, and establish a process for rotation.
Require HTTPS, limit request sizes, validate the expected content type, and reject malformed requests early. Do not reveal detailed signature-validation errors to unauthenticated callers.
Use the provider's documented signing format exactly. Some providers sign only the body, while others include a timestamp or additional fields. Do not add timestamp checks or change the signed message format unless the provider supports that protocol.
Keep replay protection shared across application instances. The operation that claims an event ID must be atomic, not a separate check followed by an insert. A database unique constraint or a Redis set-if-absent operation can provide this behavior.
Finally, separate request authentication from business processing. Signature verification establishes whether the request is authentic under the configured scheme; application validation determines whether the event is valid for your business; idempotency prevents duplicate effects. These are separate responsibilities and should be tested separately.
Advantages and Limitations
Advantages
HMAC verification detects forged or modified signed content.
Timestamp validation limits the period in which a captured request can be reused.
Durable event deduplication prevents repeated acceptance of the same event.
Idempotent processing improves reliability when providers retry deliveries.
A durable inbox makes failures easier to recover from without losing events.
Limitations
Shared secrets must be protected and rotated when necessary.
Timestamp validation depends on reasonable clock synchronization.
Replay protection requires storage and atomic operations.
A valid signature does not guarantee that the payload is valid for the application.
Exactly-once business effects are not automatically guaranteed by webhook delivery or deduplication alone. They require careful persistence, transaction boundaries, and idempotent handling.
Summary
Webhook security requires more than accepting a request over HTTPS. Verify the signature against the exact content defined by the provider's protocol, compare signatures safely, and validate a signed timestamp when the protocol supports one.
Then use an atomic, shared replay store to recognize previously accepted events. For dependable processing, persist events before acknowledging them and make business operations idempotent.
Together, these controls help prevent forged requests, reduce replay attacks, and make webhook integrations more resilient when networks fail or providers retry deliveries.
Jasen FiciPosted Oct 21, 2026, 7:22 AM
Nice write-up. It's in today's DotNetNews: https://dotnetnews.beehiiv.com/p/new-post-1407