.NET Webhook Security: Stop Replay Attacks 2026

Listen to this article · 14 min listen

Webhooks are the glue for modern services, letting them talk in real time. But that convenience comes with a huge security risk, especially from replay attacks. An attacker just has to grab a legit webhook payload and fire it at your endpoint again which could cause all sorts of chaos like duplicate payments or messed-up user data. If you’re building with .NET, you absolutely have to build defenses against this to protect your data and keep things running correctly.

Key Takeaways

  • You’ve got to verify every incoming webhook with a solid HMAC signature check using .NET’s HMACSHA256 to prove the payload is legit.
  • Throw out any webhook that’s older than 5 minutes. This strict timestamp check kills most delayed replay attempts.
  • Keep a short-lived list of unique request IDs (nonces) in a distributed cache like Redis so you never process the same payload twice.
  • Build your signature, timestamp, and nonce checks into ASP.NET Core middleware so they run on every request before your actual business code.
  • Cycle your webhook secrets often and keep an eye on your logs for failed signature checks or repeated nonces, they’re a dead giveaway of an attack.

1. Implement HMAC Signature Verification

Your first job when a webhook comes in is to check its Hash-based Message Authentication Code (HMAC) signature verification. This is non-negotiable for stopping replay attacks and just about any other tampering. The signature proves two things: the webhook really came from the service you expect (like GitHub or Stripe), and the payload wasn’t messed with along the way. Most providers already send a signature header with every request, you just have to do the work to verify it.

The process is straightforward: you and the webhook provider share a secret key that nobody else has. When a request hits your server, you take the raw request body, use your copy of the secret key to calculate what the signature *should* be, and then compare your result to the signature they sent in the header. They don’t match? The request is garbage. Reject it.

In an ASP.NET Core app, the workflow looks like this:

  1. Retrieve the Secret Key: Pull your secret from a secure location like Azure Key Vault or AWS Secrets Manager, not from your `appsettings.json`. Never hardcode them. For this example, let’s assume it’s retrieved from configuration: _configuration["WebhookSecrets:ProviderX"].
  2. Extract the Signature Header: Find the signature header from the provider. GitHub uses X-Hub-Signature-256, Stripe uses Stripe-Signature, so you’ll need to know which one to look for.
  3. Read the Raw Request Body: Pay attention here. You have to calculate the HMAC on the exact raw bytes of the body. ASP.NET Core likes to deserialize things for you which consumes the request stream, so you absolutely must enable buffering to read the stream and then rewind it. If you calculate the signature on a deserialized C# object, it will fail every time.
  4. Compute Your Signature: Fire up System.Security.Cryptography.HMACSHA256 and do the math.

Here’s a C# snippet that would live in a custom middleware or action filter:


public async Task InvokeAsync(HttpContext context)
{ // ... other middleware logic ... context.Request.EnableBuffering(). Using (var reader = new StreamReader(context.Request.Body, Encoding.UTF8, true, 1024, true)) { var requestBody = await reader.ReadToEndAsync(). Context.Request.Body.Position = 0; // Reset stream for subsequent readers var secret = _configuration["WebhookSecrets:GitHub"]; // Example secret var signatureHeader = context.Request.Headers["X-Hub-Signature-256"].FirstOrDefault(). If (string.IsNullOrEmpty(signatureHeader)) { _logger.LogWarning("Webhook received without signature header."). Context.Response.StatusCode = StatusCodes.Status401Unauthorized. Return; } var expectedSignature = signatureHeader.Replace("sha256=", ""). Var computedHash = ComputeHmacSha256(requestBody, secret). If (!AreSignaturesEqual(expectedSignature, computedHash)) { _logger.LogWarning("Invalid webhook signature detected."). Context.Response.StatusCode = StatusCodes.Status401Unauthorized. Return; } } await _next(context);
} private string ComputeHmacSha256(string data, string key)
{ var keyBytes = Encoding.UTF8.GetBytes(key). Var dataBytes = Encoding.UTF8.GetBytes(data). Using (var hmac = new HMACSHA256(keyBytes)) { var hash = hmac.ComputeHash(dataBytes). Return BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant(); }
} private bool AreSignaturesEqual(string signature1, string signature2)
{ // Constant-time comparison to prevent timing attacks if (signature1.Length != signature2.Length) { return false; } var result = 0. For (int i = 0. I < signature1.Length; i++) { result |= signature1[i] ^ signature2[i]; } return result == 0;
}

Pro Tip: When you compare the two signatures, you can't just use a simple `string == string` check. That's a vulnerability. You must use a constant-time comparison function like the AreSignaturesEqual example. Standard string comparisons bail out at the first mismatched character, and an attacker can use that timing difference to slowly guess your secret key. It sounds theoretical, but it's a real attack vector.

Key Defenses Against .NET Webhook Replay Attacks
HMAC Signature Verification

Critical

Timestamp Validation Window

5 Minutes Max

Distributed Nonce Cache

Unique ID Check

Early Middleware Checks

Before Logic

Firms Breached (Related)

72%

2. Validate Timestamps and Implement a Time Window

An HMAC signature check is great, but it won't stop an attacker who immediately replays a valid request they just captured. That's where a timestamp comes in. The sending service needs to include a timestamp in a header or the payload, and on your end, you'll only accept requests that have arrived within a very tight time window. Anything too old gets thrown out, no questions asked.

Many services already do this for you. Stripe, for instance, embeds a `t=` timestamp parameter right into its `Stripe-Signature` header. Others, like GitHub, don't include one by default, which means you might have to work with the sender to get them to add a custom header like `X-Webhook-Timestamp`.

Integrating the check is simple:

  1. Extract Timestamp: Pull the timestamp out of the header.
  2. Compare with Current Time: See how old it is by comparing the incoming Unix time to your server's current UTC time.
  3. Define a Time Window: Reject anything older than a few minutes. A 5-minute window is a solid, common choice, it gives enough of a buffer for network lag or slight clock drift without being too generous.

Picking up from the previous C# code:


// ... inside InvokeAsync after signature validation ... var timestampHeader = context.Request.Headers["X-Webhook-Timestamp"].FirstOrDefault(); // Custom timestamp header if (string.IsNullOrEmpty(timestampHeader) || !long.TryParse(timestampHeader, out var unixTimestamp))
{ _logger.LogWarning("Webhook received without valid timestamp header."). Context.Response.StatusCode = StatusCodes.Status401Unauthorized. Return;
} var receivedTime = DateTimeOffset.FromUnixTimeSeconds(unixTimestamp).UtcDateTime. Var currentTime = DateTime.UtcNow. Var timeDifference = Math.Abs((currentTime - receivedTime).TotalMinutes). Const double allowedTimeSkewMinutes = 5.0; // 5 minutes tolerance if (timeDifference > allowedTimeSkewMinutes)
{ _logger.LogWarning("Webhook timestamp outside allowed window. Received: {ReceivedTime}, Current: {CurrentTime}", receivedTime, currentTime). Context.Response.StatusCode = StatusCodes.Status401Unauthorized. Return;
} // ... rest of the pipeline ...

Common Mistake: Checking the timestamp before you've verified the signature. If you do that, an attacker can just send you garbage with a fake, recent timestamp. The signature is what proves the timestamp (and the rest of the payload) is legitimate, so you must always, always validate the signature first.

3. Implement Nonce-Based Replay Protection

Okay, so you've got HMAC and a 5-minute timestamp window. What happens if an attacker is quick and replays the request inside that 5-minute window? Your first two checks will pass. This is the problem that nonces (numbers used once) solve. A nonce is just a unique ID for every single request. Your job is to keep a list of all the nonces you've seen in the last few minutes and reject any that you've already processed.

This unique ID could be a GUID, a special request ID from the sender, or something you generate by hashing the payload and timestamp. A GUID is usually the easiest and safest bet. Whatever you use, it just has to be unique for every event and not easily guessable.

  1. Extract Nonce: The sender has to include a nonce in a header, like X-Webhook-Nonce.
  2. Store Nonces: You need a place to store recent nonces. A relational database is too slow for this. You want a fast key-value store like Redis. It's perfect because it's fast and has built-in support for key expiration (TTL).
  3. Check for Existence: When a request comes in, you ask Redis: "Have I seen this nonce before?"
  4. Add Nonce: If Redis says yes, you've caught a replay attack. Reject the request. If it's a new nonce, you add it to Redis with a TTL that's a bit longer than your timestamp window, say, 10 minutes for a 5-minute window, just to be safe.

Here's how you might do it with StackExchange.Redis in your ASP.NET Core middleware:


// ... inside InvokeAsync after timestamp validation ... var nonce = context.Request.Headers["X-Webhook-Nonce"].FirstOrDefault(). If (string.IsNullOrEmpty(nonce))
{ _logger.LogWarning("Webhook received without nonce header."). Context.Response.StatusCode = StatusCodes.Status401Unauthorized. Return;
} var redisDb = _redisConnectionMultiplexer.GetDatabase(). Var nonceKey = $"webhook:nonce:{nonce}"; // Check if nonce already exists
var isNonceAlreadyUsed = await redisDb.KeyExistsAsync(nonceKey). If (isNonceAlreadyUsed)
{ _logger.LogWarning("Webhook replay attack detected: nonce '{Nonce}' already used.", nonce). Context.Response.StatusCode = StatusCodes.Status401Unauthorized. Return;
} // Add nonce to Redis with an expiration
// Set TTL slightly longer than your timestamp window, e.g., 10 minutes
await redisDb.StringSetAsync(nonceKey, "used", TimeSpan.FromMinutes(10)); // ... rest of the pipeline ...

Pro Tip: Make sure the sender is actually generating unique nonces. A simple incrementing number won't cut it in a distributed system where multiple servers could be firing events. A UUID (what .NET calls a GUID) is a far more reliable choice. Also, your nonce cache (like Redis) becomes a single point of failure. If it goes down, you could end up blocking all incoming webhooks, so make sure it's highly available.

4. Centralize Webhook Security Logic with Middleware

If you're putting all this security logic directly into your controller actions, you're making a huge mistake. It's a maintenance nightmare and you're bound to forget a check somewhere. The right way to do this in .NET is to bundle all three checks, signature, timestamp, and nonce, into a single piece of ASP.NET Core Middleware or an Action Filter. This lets you build a single chokepoint that every single webhook request must pass through before it gets anywhere near your actual business logic.

Middleware is a good fit here because it gives you clean access to the raw request stream, which you need for the HMAC calculation. You can then apply this middleware to all requests or just the ones hitting your `/webhooks` endpoints. Creating the middleware is standard procedure:

  1. Define a class (e.g., WebhookSecurityMiddleware) with a constructor accepting RequestDelegate next and an InvokeAsync method accepting HttpContext context.
  2. Use dependency injection to get your IConfiguration (for secrets), ILogger, and your Redis client.
  3. Implement the logic from steps 1, 2, and 3 within InvokeAsync.
  4. Register the middleware in your Program.cs (or Startup.cs in older versions):

// In Program.cs
app.UseMiddleware<WebhookSecurityMiddleware>(); // Or, for specific paths:
app.Map("/webhooks", builder =>
{ builder.UseMiddleware<WebhookSecurityMiddleware>(). Builder.UseRouting(). Builder.UseEndpoints(endpoints => { endpoints.MapControllers(); });
});

With this setup, you get one place to manage all your webhook security. If any of the checks fail, signature, timestamp, or nonce, the middleware immediately kills the request and sends back an HTTP 401 Unauthorized or 403 Forbidden. Your application code never even sees the bad request, which saves server resources and prevents any chance of data corruption.

Common Mistake: Doing these checks too late. If you try to verify the signature inside your controller action, ASP.NET's model binding has probably already happened. That means the request body stream has been read and consumed, and you're now working with a C# object. Your HMAC calculation will be wrong because it wasn't done on the original raw byte stream. Do it early in the middleware.

5. Monitor and Alert for Failed Validations

Putting these security checks in place is good, but they're useless if you don't listen to what they're telling you. You need solid monitoring and alerting. Every single time a signature check fails, a timestamp is too old, or a nonce gets reused, that's a security event. You have to log it, and you have to log it with enough detail to be useful.

Configure your logger, whether it's Serilog, NLog, or just the built-in one, to ship these security events to a centralized system like Elastic Stack, Splunk, or Azure Monitor. Once the data is there, you can build alerts on top of it.

Key metrics and alerts to configure:

  • Signature Validation Failures: A sudden burst of these could mean someone's trying to guess your secret, or maybe a secret just got rotated incorrectly.
  • Timestamp Validation Failures: A lot of these might point to a clock sync problem, but it could also be someone trying to replay very old requests.
  • Nonce Reuse Detections: This is the clearest signal you've got. If you see this, you are actively blocking a replay attack. A high number means someone is trying very hard.
  • HTTP 401/403 Responses from Webhook Endpoints: Keep an eye on the overall rate of HTTP 401/403 errors on your webhook endpoints. It's a good summary metric.

For example, you could write a Kusto Query Language (KQL) query in Azure Monitor that triggers an alert if it sees more than 10 `LogWarning` events from your `WebhookSecurityMiddleware` in a 5-minute period. This is how you give your security team a heads-up so they can react to a potential attack while it's happening, not after.

Pro Tip: Be smart about your logging levels. A single failed signature might just be a LogWarning. A high-velocity burst of reused nonces from the same IP? That's probably a LogError. And for goodness sake, don't log the webhook secret itself. Log contextual info that's actually useful for an investigation, like the originating IP, which check failed, and maybe a hash of the request body.

Locking down your webhooks from replay attacks comes down to these layers of defense: HMAC signatures, tight timestamp windows, and nonce tracking. By building these checks into ASP.NET Core middleware and wiring up alerts for failures, you can make your systems much more resilient. This isn't just a nice-to-have. It's basic due diligence for protecting your data within any interconnected application ecosystem.

What is a webhook replay attack?

It's when an attacker captures a valid webhook request, payload, signature, and all, and just sends it to your server again later. Without the right defenses, your system will happily process the same event twice, which can lead to double charges, duplicate data, and all kinds of problems.

Why isn't HMAC verification alone sufficient to prevent replay attacks?

HMAC only proves that a payload is authentic and hasn't been changed. It says nothing about *when* it was sent. An attacker can capture a perfectly valid, signed request and just send it over and over, and each one will still pass the HMAC check because, from a cryptographic standpoint, it's a valid message. You need timestamps and nonces to tell if you've seen that specific valid message before.

How long should the time window for timestamp validation be?

Five minutes is the standard, safe answer. It's short enough to limit the window for an attacker but long enough to account for normal network delays and minor clock differences between servers. If you go much shorter, say 2 minutes, you risk rejecting legitimate webhooks that just got stuck in traffic, but it might be necessary for very high-security systems.

What are the best practices for storing webhook secrets in a .NET application?

Never, ever hardcode them or check them into source control. For local development, .NET's Secret Manager is fine. For production, the secrets must live in a proper vault like Azure Key Vault, AWS Secrets Manager, or HashiCorp Vault. Then you use Identity and Access Management (IAM) policies to strictly control which services can read them at runtime.

Can I use a database instead of Redis for nonce storage?

You could, but you probably shouldn't. A traditional relational database adds too much latency for what should be a lightning-fast check, especially if you're getting a lot of webhooks. An in-memory store like Redis is built for this kind of work, it's extremely fast for the "does this key exist?" check and the time-to-live (TTL) feature is exactly what you need for expiring old nonces. Using a SQL database here is just asking for a performance bottleneck.

Editorial Team

The Editorial Team at Code & Coffee is a group of writers and editors who cover technology, products, and how teams ship. They research each story and publish under a shared desk byline — experts in their fields, working as one team.