Webhook Security: HMAC Signatures and Replay Attack Prevention

Webhooks are everywhere, but their security is often an afterthought. How HMAC signature verification and replay protection work in practice.

Jean-Pierre Broeders

Freelance DevOps Engineer

March 21, 20268 min. read
Webhook Security: HMAC Signatures and Replay Attack Prevention

Webhook Security: HMAC Signatures and Replay Attack Prevention

Webhooks are a bit weird when you think about it. An external service fires data at an endpoint, and the receiving application just trusts it. No authentication handshake, no token exchange — just a POST request to a URL someone configured ages ago. And then everybody acts surprised when things go sideways.

The concept itself is fine. Webhooks are efficient and straightforward. The issue is that most implementations skip the security part entirely. No signature checks, no timestamp validation, no idempotency. It's an open invitation.

Why Webhook Endpoints Are Vulnerable

A typical webhook endpoint looks something like this:

[HttpPost("api/webhooks/payment")]
public IActionResult HandlePayment([FromBody] PaymentEvent payload)
{
    ProcessPayment(payload);
    return Ok();
}

This accepts literally anything. Anyone who knows the URL can send a fabricated payment event. No verification, no checks. In a payment application, that's straight-up dangerous.

Three core problems need solving:

  • Authenticity — how do you know the request actually came from the expected service?
  • Integrity — how do you know the payload wasn't tampered with in transit?
  • Replay attacks — how do you prevent a valid request from being replayed?

HMAC Signature Verification

HMAC (Hash-based Message Authentication Code) addresses the first two problems. The sender computes a hash of the payload using a shared secret and includes that hash as a header. The receiver computes the same hash and compares.

public class WebhookSignatureValidator
{
    private readonly byte[] _secretKey;

    public WebhookSignatureValidator(string secret)
    {
        _secretKey = Encoding.UTF8.GetBytes(secret);
    }

    public bool IsValid(string payload, string receivedSignature)
    {
        using var hmac = new HMACSHA256(_secretKey);
        var computedHash = hmac.ComputeHash(Encoding.UTF8.GetBytes(payload));
        var computedSignature = Convert.ToHexString(computedHash).ToLowerInvariant();

        // Timing-safe comparison to prevent timing attacks
        return CryptographicOperations.FixedTimeEquals(
            Encoding.UTF8.GetBytes(computedSignature),
            Encoding.UTF8.GetBytes(receivedSignature)
        );
    }
}

Two details that actually matter here. First: use CryptographicOperations.FixedTimeEquals instead of a regular string comparison. A normal == check short-circuits on the first mismatch, which lets an attacker figure out the correct signature character by character through timing analysis. Second: compute the HMAC over the raw body, not a deserialized-then-reserialized object. JSON serialization can reorder properties, and then the hash won't match.

Reading the Raw Body in ASP.NET Core

By default, ASP.NET Core consumes the request body during model binding. For signature verification, the original string is needed:

[HttpPost("api/webhooks/payment")]
public async Task<IActionResult> HandlePayment()
{
    // Read raw body BEFORE deserialization
    Request.EnableBuffering();
    using var reader = new StreamReader(Request.Body, leaveOpen: true);
    var rawBody = await reader.ReadToEndAsync();
    Request.Body.Position = 0;

    var signature = Request.Headers["X-Webhook-Signature"].FirstOrDefault();
    if (string.IsNullOrEmpty(signature) || !_validator.IsValid(rawBody, signature))
    {
        return Unauthorized();
    }

    var payload = JsonSerializer.Deserialize<PaymentEvent>(rawBody);
    ProcessPayment(payload!);
    return Ok();
}

EnableBuffering() makes the body stream seekable. Without it, the stream is empty after the first read.

Replay Attack Prevention

HMAC verification confirms a request is authentic, but doesn't protect against reuse. An attacker who intercepts a valid request can send it again — the signature still checks out. With payments, that could mean charging a customer twice.

The solution has two parts: timestamp validation and idempotency keys.

Timestamp Validation

Most webhook providers include a timestamp. Check that it's not too far in the past (or future):

public bool IsTimestampValid(string timestampHeader, int toleranceSeconds = 300)
{
    if (!long.TryParse(timestampHeader, out var unixTimestamp))
        return false;

    var webhookTime = DateTimeOffset.FromUnixTimeSeconds(unixTimestamp);
    var drift = Math.Abs((DateTimeOffset.UtcNow - webhookTime).TotalSeconds);

    return drift <= toleranceSeconds;
}

Five minutes of tolerance is standard. Tighter is possible but account for clock drift between servers. Critical detail: the timestamp must be part of the HMAC calculation. Otherwise an attacker can simply attach a fresh timestamp to an old signature.

Idempotency via Event IDs

Store processed event IDs and reject duplicates:

public class WebhookIdempotencyStore
{
    private readonly IDistributedCache _cache;

    public async Task<bool> IsProcessed(string eventId)
    {
        var existing = await _cache.GetStringAsync($"webhook:{eventId}");
        return existing != null;
    }

    public async Task MarkProcessed(string eventId)
    {
        await _cache.SetStringAsync(
            $"webhook:{eventId}",
            "1",
            new DistributedCacheEntryOptions
            {
                AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(48)
            }
        );
    }
}

A 48-hour TTL usually does the job. Longer retention can cause unnecessary memory pressure in Redis. Shorter, and there's a window for late replays.

Complete Middleware

Everything combined in an ASP.NET Core middleware:

public class WebhookSecurityMiddleware
{
    private readonly RequestDelegate _next;
    private readonly WebhookSignatureValidator _validator;
    private readonly WebhookIdempotencyStore _store;

    public async Task InvokeAsync(HttpContext context)
    {
        if (!context.Request.Path.StartsWithSegments("/api/webhooks"))
        {
            await _next(context);
            return;
        }

        context.Request.EnableBuffering();
        using var reader = new StreamReader(context.Request.Body, leaveOpen: true);
        var body = await reader.ReadToEndAsync();
        context.Request.Body.Position = 0;

        var signature = context.Request.Headers["X-Webhook-Signature"].FirstOrDefault();
        var timestamp = context.Request.Headers["X-Webhook-Timestamp"].FirstOrDefault();
        var eventId = context.Request.Headers["X-Webhook-Id"].FirstOrDefault();

        if (string.IsNullOrEmpty(signature) || !_validator.IsValid(body, signature))
        {
            context.Response.StatusCode = 401;
            return;
        }

        if (!string.IsNullOrEmpty(timestamp) && !_validator.IsTimestampValid(timestamp))
        {
            context.Response.StatusCode = 403;
            return;
        }

        if (!string.IsNullOrEmpty(eventId) && await _store.IsProcessed(eventId))
        {
            context.Response.StatusCode = 200; // OK but don't reprocess
            return;
        }

        await _next(context);

        if (!string.IsNullOrEmpty(eventId))
            await _store.MarkProcessed(eventId);
    }
}

Common Mistakes

A few things that regularly go wrong in production:

  • Hardcoded secrets — use environment variables or a secret manager. Sounds obvious. Still happens constantly.
  • Signature comparison with == — timing-safe comparison isn't overkill, it's necessary.
  • Re-serializing the body — the hash must be computed over the exact same bytes. One extra space and the signature fails.
  • No retry logic on the provider side — when the handler fails, the provider should retry. Always return a fast response (200/202) and process asynchronously.
  • Logging the secret — never log the webhook secret. Structured logging frameworks sometimes capture more context than expected.

Wrapping Up

Webhook security doesn't need to be complicated. HMAC signature verification, timestamp checks, and idempotency keys cover the vast majority of attack vectors. The implementation takes an afternoon. Skipping it can cost considerably more.

Want to stay updated?

Subscribe to my newsletter or get in touch for freelance projects.

Get in Touch