Webhook Security: HMAC Signatures en Replay Attacks Voorkomen

Webhooks zijn overal, maar de beveiliging ervan wordt vaak vergeten. Hoe HMAC signature verification en replay protection werken in de praktijk.

Jean-Pierre Broeders

Freelance DevOps Engineer

21 maart 20268 min. leestijd
Webhook Security: HMAC Signatures en Replay Attacks Voorkomen

Webhook Security: HMAC Signatures en Replay Attacks Voorkomen

Webhooks zijn eigenlijk best gek als je er even over nadenkt. Een externe service stuurt data naar een endpoint, en de ontvangende applicatie vertrouwt dat het klopt. Geen authenticatie, geen token exchange — gewoon een POST request naar een URL die iemand ooit heeft geconfigureerd. En dan verbazen we ons dat er dingen misgaan.

Het probleem zit niet in het concept. Webhooks zijn efficiënt en simpel. Het probleem is dat de meeste implementaties de beveiliging overslaan. Geen signature check, geen timestamp validatie, geen idempotency. Een open deur voor aanvallers.

Waarom Webhook Endpoints Kwetsbaar Zijn

Een typisch webhook endpoint ziet er zo uit:

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

Dit accepteert letterlijk alles. Iedereen die de URL kent kan een gefabriceerd payment event sturen. Geen verificatie, geen controle. In een betaalapplicatie is dat ronduit gevaarlijk.

De drie kernproblemen:

  • Authenticiteit — hoe weet je dat het request echt van de verwachte service komt?
  • Integriteit — hoe weet je dat de payload onderweg niet is aangepast?
  • Replay attacks — hoe voorkom je dat een geldig request opnieuw wordt afgespeeld?

HMAC Signature Verification

HMAC (Hash-based Message Authentication Code) lost de eerste twee problemen op. Het principe: de verzender berekent een hash van de payload met een gedeeld secret, en stuurt die hash mee als header. De ontvanger berekent dezelfde hash en vergelijkt.

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 vergelijking om timing attacks te voorkomen
        return CryptographicOperations.FixedTimeEquals(
            Encoding.UTF8.GetBytes(computedSignature),
            Encoding.UTF8.GetBytes(receivedSignature)
        );
    }
}

Twee details die er echt toe doen. Ten eerste: gebruik CryptographicOperations.FixedTimeEquals in plaats van een gewone string comparison. Een normale == vergelijking stopt bij het eerste verschil, waardoor een aanvaller via timing analysis karakter voor karakter de juiste signature kan achterhalen. Ten tweede: bereken de HMAC over de ruwe body, niet over een gedeserialiseerd en opnieuw geserialiseerd object. JSON serialisatie kan de volgorde van properties wijzigen, en dan klopt de hash niet meer.

De Ruwe Body Uitlezen in ASP.NET Core

Standaard consumed ASP.NET Core de request body bij model binding. Voor signature verificatie is de originele string nodig:

[HttpPost("api/webhooks/payment")]
public async Task<IActionResult> HandlePayment()
{
    // Lees de ruwe body VOOR deserialisatie
    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() zorgt ervoor dat de body stream opnieuw gelezen kan worden. Zonder die call is de stream na het eerste lezen leeg.

Replay Attack Prevention

HMAC verificatie garandeert dat een request authentiek is, maar beschermt niet tegen hergebruik. Een aanvaller die een geldig request onderschept kan het opnieuw versturen — de signature klopt immers nog steeds. Bij betalingen kan dat betekenen dat een klant dubbel wordt afgeschreven.

De oplossing bestaat uit twee onderdelen: timestamp validatie en idempotency keys.

Timestamp Validatie

De meeste webhook providers sturen een timestamp mee. Controleer of die niet te ver in het verleden (of de toekomst) ligt:

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;
}

Vijf minuten tolerantie is gangbaar. Strakker instellen kan, maar houd rekening met clock drift tussen servers. Belangrijk: de timestamp moet onderdeel zijn van de HMAC berekening. Anders kan een aanvaller gewoon een nieuwe timestamp meesturen met een oude signature.

Idempotency via Event IDs

Sla verwerkte event IDs op en weiger duplicaten:

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)
            }
        );
    }
}

Een TTL van 48 uur is doorgaans voldoende. Langer bewaren kan bij Redis tot onnodige memory pressure leiden. Korter en er is een window voor late replays.

Complete Middleware

Alles gecombineerd in een 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 maar niet opnieuw verwerken
            return;
        }

        await _next(context);

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

Veelgemaakte Fouten

Een paar dingen die regelmatig misgaan in productie:

  • Secret hardcoded in de code — gebruik environment variables of een secret manager. Klinkt voor de hand liggend, maar het gebeurt nog steeds.
  • Signature vergelijking met == — timing-safe comparison is geen overkill, het is noodzaak.
  • Body opnieuw serialiseren — de hash moet over exact dezelfde bytes berekend worden. Eén spatie verschil en de signature klopt niet.
  • Geen retry-logica aan de provider kant — als de webhook handler faalt, moet de provider opnieuw proberen. Geef altijd een snelle response (200/202) en verwerk asynchroon.
  • Logging van het secret — nooit de webhook secret loggen. Klinkt obvious, maar structured logging frameworks pakken soms meer mee dan verwacht.

Samenvatting

Webhook security hoeft niet ingewikkeld te zijn. HMAC signature verificatie, timestamp controle en idempotency keys dekken het overgrote deel van de aanvalsvectoren af. De implementatie kost een middag. Het niet implementeren kan een stuk duurder uitpakken.

Wil je op de hoogte blijven?

Schrijf je in voor mijn nieuwsbrief of neem contact op voor freelance projecten.

Neem Contact Op