WebhookGuard 0.1.0
dotnet add package WebhookGuard --version 0.1.0
NuGet\Install-Package WebhookGuard -Version 0.1.0
<PackageReference Include="WebhookGuard" Version="0.1.0" />
<PackageVersion Include="WebhookGuard" Version="0.1.0" />
<PackageReference Include="WebhookGuard" />
paket add WebhookGuard --version 0.1.0
#r "nuget: WebhookGuard, 0.1.0"
#:package WebhookGuard@0.1.0
#addin nuget:?package=WebhookGuard&version=0.1.0
#tool nuget:?package=WebhookGuard&version=0.1.0
WebhookGuard
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
- The raw request body is buffered (capped at
MaxBodyBytes) and the stream is rewound. - The provider extracts the signature (and timestamp, if any) from the headers.
- The signing input is rebuilt from the raw bytes per the provider's scheme.
- The expected signature is computed with the pinned algorithm and compared in constant time.
- If a timestamp is configured, it must be within tolerance.
- On any failure the request is rejected with
401; the reason is available to your logs viaWebhookVerificationResult.
License
MIT © Ahmad Taghiyev
| Product | Versions 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. |
-
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.