Skip to content
fullstackhero

Concept

Webhook signing

HMAC-SHA256 payload signing for every outbound webhook delivery, secrets encrypted at rest via Data Protection, and a complete subscriber-side verification recipe.

views 0 Last updated

Every outbound webhook delivery from the kit can be signed with HMAC-SHA256. Subscribers verify the signature against the same shared secret to confirm the payload came from you and wasn’t tampered with in transit. Each attempt carries a fresh X-Webhook-Delivery-Id so receivers can dedupe retries.

Secrets are encrypted at rest

The signing secret is the HMAC key, so it must be recoverable — a one-way hash would make signing impossible. The kit therefore encrypts it with ASP.NET Data Protection (WebhookSecretProtector, purpose string FSH.Webhooks.SubscriptionSecret.v1) when the subscription is created, and decrypts it only at dispatch time:

// CreateWebhookSubscriptionCommandHandler
var protectedSecret = secretProtector.Protect(command.Secret);
var subscription = WebhookSubscription.Create(command.Url, command.Events, protectedSecret);

A database leak alone doesn’t expose signing secrets — the attacker would also need your Data Protection key ring. This is also why multi-instance hosts must share the key ring (Valkey-backed persistence): a secret encrypted on one node must decrypt on another.

Headers on every delivery

HeaderValuePurpose
Content-Typeapplication/jsonStandard JSON content
X-Webhook-EventThe event type nameRoutes the payload to the right subscriber handler
X-Webhook-Delivery-IdA fresh UUID per attemptReceiver-side idempotency / dedup key
X-Webhook-Signaturesha256={hex}HMAC-SHA256 of the raw request body using the subscription’s secret

The signature covers the body only. Since retries re-send the same body, the signature is identical across attempts; the delivery id is what changes per attempt (each attempt is its own WebhookDelivery row in the delivery log).

How the kit signs

src/Modules/Webhooks/Modules.Webhooks/Services/WebhookPayloadSigner.cs
public static class WebhookPayloadSigner
{
public static string Sign(string payload, string secret)
{
var keyBytes = Encoding.UTF8.GetBytes(secret);
var payloadBytes = Encoding.UTF8.GetBytes(payload);
var hash = HMACSHA256.HashData(keyBytes, payloadBytes);
return $"sha256={Convert.ToHexString(hash).ToLowerInvariant()}";
}
}

The dispatch job decrypts the stored secret and sets the header:

// WebhookDispatchJob (simplified)
var signingSecret = _secretProtector.Unprotect(subscription.SecretHash);
if (!string.IsNullOrEmpty(signingSecret))
{
content.Headers.Add("X-Webhook-Signature", WebhookPayloadSigner.Sign(payloadJson, signingSecret));
}

The header value is always sha256= + lowercase hex digest. No surrounding whitespace, no Base64 alternative — one format, stick to it.

Subscriber-side verification (.NET)

The receiver computes the same HMAC, then compares using a timing-safe equality check:

using System;
using System.Security.Cryptography;
using System.Text;
public static bool VerifyWebhook(string body, string signatureHeader, string secret)
{
if (string.IsNullOrEmpty(signatureHeader) || !signatureHeader.StartsWith("sha256=", StringComparison.Ordinal))
return false;
var expected = signatureHeader["sha256=".Length..];
using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret));
var hash = hmac.ComputeHash(Encoding.UTF8.GetBytes(body));
var actual = Convert.ToHexStringLower(hash);
// Timing-safe equality — critical to prevent timing-side-channel leaks
return CryptographicOperations.FixedTimeEquals(
Encoding.ASCII.GetBytes(expected),
Encoding.ASCII.GetBytes(actual));
}

The FixedTimeEquals is what protects against an attacker measuring response times to guess the signature byte-by-byte. Don’t use a plain string == for signature comparison.

Subscriber-side verification (Node / Express)

import { createHmac, timingSafeEqual } from 'node:crypto';
import express from 'express';
const app = express();
// Receive the raw body so we can re-hash it.
app.post('/webhooks/fullstackhero',
express.raw({ type: 'application/json' }),
(req, res) => {
const signature = req.get('X-Webhook-Signature') ?? '';
if (!signature.startsWith('sha256=')) return res.status(401).end();
const expected = signature.slice('sha256='.length);
const actual = createHmac('sha256', process.env.WEBHOOK_SECRET)
.update(req.body)
.digest('hex');
if (!timingSafeEqual(Buffer.from(expected, 'utf8'), Buffer.from(actual, 'utf8')))
return res.status(401).end();
// … process payload
res.status(200).end();
});

Critical detail: hash the raw bytes of the request body, not the parsed JSON object. Re-serialising the parsed object reorders fields and changes the digest.

Dedup and replay

X-Webhook-Delivery-Id is a fresh UUID per attempt. Receivers should:

  1. Track the most recent N delivery ids per subscription (in Redis / a small DB table).
  2. Treat any delivery whose id is already in the table as already-processed (idempotency).
  3. Expire entries older than ~24 hours.

That handles retries and network-layer duplicates cleanly — the kit retries failed deliveries up to 4 times (30s / 2m / 10m / 1h backoff), and each attempt’s id is distinct.

Be clear about what it does not handle: the delivery id is a header, outside the signed body, so id-dedup is not a defence against a deliberate attacker replaying a captured payload with a fresh id. For real replay defence, include a timestamp inside the signed payload and reject anything older than N minutes. The kit doesn’t ship that by default — for most integrations, TLS plus signature verification plus id-dedup is the right cost/benefit.

What signing doesn’t protect against

  • Compromised secrets. If the shared secret leaks, anyone can sign payloads. Rotate by deleting the subscription and recreating it with a fresh secret (there’s no in-place secret update); communicate the rotation to tenants out-of-band.
  • A malicious tenant attacking themselves. The signing protects the channel between you and the subscriber. It says nothing about whether the subscriber’s own systems are trustworthy.
  • TLS downgrade. Always use HTTPS endpoints in subscriptions (the URL validator accepts http and https — enforce https in production). The signature doesn’t substitute for transport encryption — anyone watching the wire can still read the payload, even if they can’t tamper with it.

Operational tips

  • Log every signature failure on your receiving side. A pattern of failures means either the subscriber’s secret is out of sync or someone’s probing.
  • Don’t return rich error messages on signature mismatch. A simple 401 — no “expected X, got Y” — denies an attacker the oracle they’d need to reverse-engineer the signing scheme.
  • Make secrets long. 32+ random bytes (openssl rand -hex 32). Short secrets are guessable through offline brute force against captured signed payloads.