WebhookGuard 0.1.0

dotnet add package WebhookGuard --version 0.1.0
                    
NuGet\Install-Package WebhookGuard -Version 0.1.0
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="WebhookGuard" Version="0.1.0" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="WebhookGuard" Version="0.1.0" />
                    
Directory.Packages.props
<PackageReference Include="WebhookGuard" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add WebhookGuard --version 0.1.0
                    
#r "nuget: WebhookGuard, 0.1.0"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package WebhookGuard@0.1.0
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=WebhookGuard&version=0.1.0
                    
Install as a Cake Addin
#tool nuget:?package=WebhookGuard&version=0.1.0
                    
Install as a Cake Tool

WebhookGuard

NuGet

Secure-by-default webhook signature verification for ASP.NET Core.

When a third party (Stripe, GitHub, a bank, a government system) calls your endpoint, you can't hand them a token — you have to verify that the request genuinely came from them and wasn't tampered with or replayed. WebhookGuard does that correctly, closing the pitfalls people get wrong when they hand-roll it.

builder.Services.AddWebhookGuard(o => o.AddStripe("stripe", config["Stripe:WebhookSecret"]));

app.MapPost("/webhooks/stripe", (StripeEvent e) => Results.Ok())
   .VerifyWebhook("stripe");   // bad / missing / replayed signature → 401, handler never runs

Why not just use a token?

A bearer token authenticates your clients calling your API. A webhook is the reverse: an outside system you don't control calls you. There's no token to issue — instead the sender signs each request, and you verify the signature. Compared to a static shared token, a signature also:

  • never travels on the wire — only a hash derived from it does;
  • covers the exact body — a one-byte change invalidates it;
  • carries a timestamp — so old captured requests can't be replayed.

Install

dotnet add package WebhookGuard

Targets net8.0, net9.0, net10.0.

Built-in providers

Provider Header Scheme
AddStripe Stripe-Signature HMAC-SHA256 over t.body, hex, replay-protected
AddGitHub X-Hub-Signature-256 HMAC-SHA256 over body, sha256= hex
AddShopify X-Shopify-Hmac-Sha256 HMAC-SHA256 over body, Base64
AddSlack X-Slack-Signature HMAC-SHA256 over v0:timestamp:body, hex, replay-protected

Note: GitHub and Shopify do not send a timestamp, so their signatures carry no replay protection on their own — a captured-and-resent request stays valid. See Replay protection & idempotency.

builder.Services.AddWebhookGuard(o =>
{
    o.AddStripe("stripe",  config["Stripe:WebhookSecret"]);
    o.AddGitHub("github",  config["GitHub:WebhookSecret"]);
    o.AddShopify("shopify", config["Shopify:Secret"]);
    o.AddSlack("slack",    config["Slack:SigningSecret"]);
});

Generic HMAC (symmetric)

For any shared-secret webhook not covered by a preset:

o.AddHmac("custom", x =>
{
    x.Secret           = config["Custom:Secret"];
    x.SignatureHeader  = "X-Signature";
    x.SignaturePrefix  = "sha256=";              // stripped before decoding (optional)
    x.Encoding         = SignatureEncoding.Hex;  // or Base64
    x.Algorithm        = HmacHashAlgorithm.Sha256;

    // Optional replay protection from a separate timestamp header:
    x.TimestampHeader     = "X-Timestamp";
    x.TimestampFormat     = TimestampFormat.UnixSeconds; // or UnixMilliseconds
    x.TimestampTolerance  = TimeSpan.FromMinutes(5);

    // Optional: control exactly what gets signed ({timestamp} and {body} are substituted)
    x.SigningInputTemplate = "{timestamp}.{body}";
});

Asymmetric (public-key: RSA / ECDSA)

When the sender signs with a private key and publishes the public key (Apple, open-banking, many B2B integrations). You hold only the public key, so you can verify but never forge — and even a full compromise of your service can't impersonate the sender.

o.AddAsymmetric("partner", x =>
{
    x.PublicKeyPem    = config["Partner:PublicKeyPem"];   // -----BEGIN PUBLIC KEY-----
    x.Algorithm       = AsymmetricSignatureAlgorithm.RS256; // RS/PS/ES 256/384/512
    x.SignatureHeader = "X-Signature";
    x.Encoding        = SignatureEncoding.Base64;
});

The algorithm is pinned here and never read from the request, which is exactly what defeats the classic algorithm-confusion and alg:none attacks (see below).

Three ways to guard an endpoint

Minimal API — endpoint filter:

app.MapPost("/webhooks/stripe", Handle).VerifyWebhook("stripe");

MVC — attribute (runs before model binding):

[HttpPost("/webhooks/stripe")]
[VerifyWebhook("stripe")]
public IActionResult Handle() { ... }

Middleware — guard a path prefix:

app.UseWebhookVerification("/webhooks/stripe", "stripe");

Need the result yourself? Inject IWebhookVerifier:

var result = await verifier.VerifyAsync(Request, "stripe");
if (!result.IsValid) return Unauthorized();

The security model

Hand-rolled webhook verification fails in well-known ways. WebhookGuard is built so you can't hit them:

Pitfall How WebhookGuard handles it
Algorithm confusion (RS256→HS256) The algorithm is fixed per provider; the request's claimed algorithm is never consulted.
alg:none Same — there is no "read the algorithm from the request" code path.
Timing attacks Signatures are compared with CryptographicOperations.FixedTimeEquals, never == / SequenceEqual.
Replay When a timestamp is present it must fall inside a tolerance window (default 5 min). See the caveat below.
"Verify what you process" Verification runs on the raw body bytes, then the body is rewound so your handler reads the exact same bytes.
Malformed input → exceptions Bad signatures/keys produce a clean rejection, never a thrown exception, on attacker-controlled paths.
Oversized bodies Bodies above MaxBodyBytes (default 1 MB) are rejected before buffering completes.
Secret leaks via logs Failures are logged with a reason, never the secret or signature; callers see only a generic 401.

Replay protection & idempotency

Be precise about what the timestamp check buys you. It rejects requests older than the tolerance window — but it is not full replay protection:

  • Within the window, the same valid request can be replayed and will still pass.
  • GitHub and Shopify send no timestamp at all, so their signatures give no replay protection on their own.

A signature proves authenticity and integrity, not uniqueness. For true once-only processing you also need idempotency: record each event's id (e.g. Stripe's id, GitHub's X-GitHub-Delivery) and ignore one you've already handled. This matters in normal operation too — providers retry on non-2xx, so the same event legitimately arrives more than once.

WebhookGuard verifies the signature; deduplication is your handler's job (or a dedicated idempotency layer).

Defence in depth

Pair signature verification with what the sender offers: an IP allowlist at your firewall, idempotency as above, and TLS (which provides the confidentiality a signature does not).

How it works

  1. The raw request body is buffered (capped at MaxBodyBytes) and the stream is rewound.
  2. The provider extracts the signature (and timestamp, if any) from the headers.
  3. The signing input is rebuilt from the raw bytes per the provider's scheme.
  4. The expected signature is computed with the pinned algorithm and compared in constant time.
  5. If a timestamp is configured, it must be within tolerance.
  6. On any failure the request is rejected with 401; the reason is available to your logs via WebhookVerificationResult.

License

MIT © Ahmad Taghiyev

Product Compatible and additional computed target framework versions.
.NET net8.0 is compatible.  net8.0-android was computed.  net8.0-browser was computed.  net8.0-ios was computed.  net8.0-maccatalyst was computed.  net8.0-macos was computed.  net8.0-tvos was computed.  net8.0-windows was computed.  net9.0 is compatible.  net9.0-android was computed.  net9.0-browser was computed.  net9.0-ios was computed.  net9.0-maccatalyst was computed.  net9.0-macos was computed.  net9.0-tvos was computed.  net9.0-windows was computed.  net10.0 is compatible.  net10.0-android was computed.  net10.0-browser was computed.  net10.0-ios was computed.  net10.0-maccatalyst was computed.  net10.0-macos was computed.  net10.0-tvos was computed.  net10.0-windows was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.
  • net10.0

    • No dependencies.
  • net8.0

    • No dependencies.
  • net9.0

    • No dependencies.

NuGet packages

This package is not used by any NuGet packages.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
0.1.0 39 6/3/2026

Initial release: HMAC (Stripe/GitHub/Shopify/Slack presets + generic) and asymmetric RSA/ECDSA verification, with pinned algorithms, constant-time comparison, replay protection, and raw-body verification. Available as an endpoint filter, MVC attribute, and middleware.