Webhook Hardening (v1.25)
The dashboard webhook receive endpoint
(POST /flows/api/webhook/{idOrSlug}) ships an opt-in security pipeline
that turns it from a developer convenience into a production-grade public
ingestion surface. The pipeline runs four gates in order:
IpAllowlist → BodySizeCap → SignatureVerify → ReplayCheck → RateLimit → IdempotencyDedup → Dispatch
Each gate is opt-in via manifest fields and is a no-op when not configured.
With the default WebhookEnforcementMode = Off the endpoint behaves exactly
as in v1.24 — every gate is skipped.
Three enforcement modes
| Mode | Gate behaviour | HTTP response | When to use |
|---|---|---|---|
Off |
Skipped | Same as v1.24 | Greenfield + flows that haven't migrated |
Audit |
Run + log + metrics + DLQ | Always 202 (accept) | One release before flipping to Enforce to confirm legitimate traffic still validates |
Enforce |
Run + log + metrics + DLQ + reject | 4xx on failure | Production lock-down |
Configure globally:
builder.Services.AddFlowDashboard(opts => opts.UseWebhookSecurity(sec =>
{
sec.UseEnforcementMode(WebhookEnforcementMode.Audit);
sec.UseMaxBodyBytes(1_048_576);
sec.UseReplayProtection(toleranceSeconds: 300);
sec.UseRateLimit(permitsPerSecond: 50, burstSize: 100, perIp: true);
sec.UseForwardedHeaders(depth: 1);
}));
Just pick a preset
For the 17 publishers that ship in PartnerSchemeRegistry, you only need
three manifest fields. Pick a scheme and the verifier fills in the wire
format (header name, algorithm, encoding, prefix) automatically:
Inputs = new Dictionary<string, object?>
{
["webhookSlug"] = "github-events",
["webhookHmacKey"] = "<secret from publisher dashboard>",
["webhookSignatureScheme"] = "GitHub",
}
Browse the Per-publisher cookbook for the other 16 schemes. If your publisher isn't on the list, see Custom scheme reference for full manifest control or Bring your own scheme to plug a custom verifier through DI.
Migrating from v1.24
v1.24 used webhookSecret / webhookSecretPrevious as the HMAC key
fields. v1.25 renames them to make the role explicit; the v1.24 names
keep working as aliases.
| v1.24 manifest field | v1.25 equivalent | Notes |
|---|---|---|
webhookSecret |
webhookHmacKey |
Legacy alias; honoured as fallback. webhookHmacKey wins when both are set; the pipeline emits EventId 4011 once per flow on the first conflict. |
webhookSecretPrevious |
webhookHmacKeyPrevious |
Same precedence rule as above. |
Existing v1.24 manifests using only webhookSecret continue to work
unchanged. Removal of the legacy names is planned for v2.0; no
[Obsolete] attribute is applied yet.
Per-publisher cookbook
Each example shows the manifest inputs needed for the named publisher. The
HMAC verifier is generic — set webhookSignatureScheme and the wire
format is resolved through PartnerSchemeRegistry. Use Custom for any
publisher not on the built-in list.
GitHub (X-Hub-Signature-256)
Inputs = new Dictionary<string, object?>
{
["webhookSlug"] = "github-events",
["webhookHmacKey"] = "<secret from github.com/.../settings/hooks>",
["webhookSignatureScheme"] = "GitHub",
["webhookNonceHeader"] = "X-GitHub-Delivery",
["webhookIpAllowListPreset"] = "github",
}
Stripe (Stripe-Signature)
Multi-value t=…,v1=… header. The verifier parses the timestamp out of
t= and uses every v1= candidate (Stripe rotates the second signing
key during rotation windows).
Inputs = new Dictionary<string, object?>
{
["webhookSlug"] = "stripe-events",
["webhookHmacKey"] = "<whsec_… from Stripe dashboard>",
["webhookSignatureScheme"] = "Stripe",
["webhookReplayToleranceSeconds"] = 300, // Stripe's recommended window
["webhookIpAllowListPreset"] = "stripe",
}
Slack (X-Slack-Signature)
v0= digest over v0:{ts}:{body}. Slack supplies the timestamp in
X-Slack-Request-Timestamp.
Inputs = new Dictionary<string, object?>
{
["webhookSlug"] = "slack-commands",
["webhookHmacKey"] = "<signing secret from api.slack.com app>",
["webhookSignatureScheme"] = "Slack",
["webhookReplayToleranceSeconds"] = 300,
}
Shopify (X-Shopify-Hmac-SHA256)
Base64 SHA-256 over the raw body.
Inputs = new Dictionary<string, object?>
{
["webhookSlug"] = "shopify-orders",
["webhookHmacKey"] = "<webhook secret from Shopify admin>",
["webhookSignatureScheme"] = "Shopify",
}
Twilio (X-Twilio-Signature)
SHA-1 base64 over {absoluteUrl}{sortedFormParams}. Twilio sends
application/x-www-form-urlencoded, not JSON — make sure your reverse
proxy preserves the form body verbatim. Twilio is the one built-in scheme
that requires AllowLegacySha1 = true.
Inputs = new Dictionary<string, object?>
{
["webhookSlug"] = "twilio-sms",
["webhookHmacKey"] = "<Auth Token from Twilio console>",
["webhookSignatureScheme"] = "Twilio",
}
Square, Zoom, Linear, Dropbox, Calendly, Bitbucket, Atlassian, Microsoft Teams
Same shape — set webhookSignatureScheme to the publisher name. See
PartnerSchemeRegistry for the exact spec each one resolves to.
Mailgun (body-resident signature)
Mailgun puts the signature triple (timestamp, token, signature) inside
the JSON body, not headers. The verifier extracts them and HMACs
{timestamp}{token}.
Inputs = new Dictionary<string, object?>
{
["webhookSlug"] = "mailgun-events",
["webhookHmacKey"] = "<Mailgun API key>",
["webhookSignatureScheme"] = "Mailgun",
}
Custom (full manifest control)
Use this only if your publisher isn't in the built-in list. ~10% of users will need it. The full field set lives in Custom scheme reference; a minimal example:
Inputs = new Dictionary<string, object?>
{
["webhookSlug"] = "myapp-events",
["webhookHmacKey"] = "<secret>",
["webhookSignatureScheme"] = "Custom",
["webhookSignatureHeader"] = "X-MyApp-Signature",
["webhookSignatureAlgorithm"] = "sha512",
}
For exotic byte compositions (e.g. AWS SigV4-style canonical requests), register a custom byte-builder strategy in DI and reference it by name — see Custom scheme reference. For a fully pluggable verifier (asymmetric, KMS, query-string carriers), see Bring your own scheme.
Zero-downtime key rotation
Set both webhookHmacKey (current) and webhookHmacKeyPrevious (rotated-out)
on the trigger. The verifier hashes the request against both keys without
short-circuiting (timing-safe). Successful matches against the previous key
emit EventId 4010 WebhookSecretRotationUsedPrevious so you can monitor
when it's safe to retire the old value.
Inputs["webhookHmacKey"] = "new-secret-2026-Q2";
Inputs["webhookHmacKeyPrevious"] = "old-secret-2026-Q1";
The v1.24 names webhookSecret / webhookSecretPrevious are legacy
aliases for the same rotation pair — see
Migrating from v1.24.
Replay protection
Two complementary defences:
- Skew window.
webhookReplayToleranceSecondsrejects any request whose timestamp drifts more than the configured number of seconds from the server's clock.0(default) disables the check. - Nonce ledger. Every accepted request registers a
(flowId, triggerKey, nonce)row inIWebhookReplayStore; a duplicate is a replay and gets409 Conflict. The nonce is either the explicitwebhookNonceHeader(e.g.X-GitHub-Delivery) orSHA-256(timestamp || body)when no header is supplied.
The WebhookReplayJanitor background service purges expired entries
every minute. For multi-replica deployments use the SQL Server or
PostgreSQL backend (see Storage)
— the in-memory store is single-process and would let a replay through
on a second replica.
Rate limiting
Token-bucket limiter built on System.Threading.RateLimiting, keyed
per-flow or per flowId|clientIp when webhookRateLimitPerIp = true.
Inputs["webhookRateLimitPermitsPerSecond"] = 10;
Inputs["webhookRateLimitBurstSize"] = 20;
Inputs["webhookRateLimitPerIp"] = true;
429 responses include a Retry-After header and emit
EventId 4003 WebhookRateLimited.
IP allow / deny list
CidrMatcher accepts every common notation for an IP allow / deny list,
mix-and-match in the same array:
| Notation | Example | Notes |
|---|---|---|
| CIDR | 10.0.0.0/8, 2001:db8::/32 |
IPv4 + IPv6 |
| Single address | 203.0.113.42 |
Auto-promoted to /32 (IPv4) / /128 (IPv6) |
| Inclusive range | 10.0.0.10-10.0.0.42 |
Bytewise compare; works for IPv6 too (2001:db8::1-2001:db8::ff) |
| Octet wildcard | 10.0.*.* |
Equivalent to the matching CIDR (10.0.0.0/16); *.*.*.* matches everything |
// Mix-and-match in any combination.
Inputs["webhookIpAllowList"] = new[]
{
"10.0.0.0/8", // CIDR
"172.16.0.10-172.16.0.42", // inclusive range
"192.168.5.*", // wildcard /24
"203.0.113.42", // single address
"2001:db8::/32", // IPv6 CIDR
"::1/128", // IPv6 loopback
};
Inputs["webhookIpDenyList"] = new[]
{
"203.0.113.0/24", // ban a /24
"198.51.100.0-198.51.100.99", // ban a range
};
The list can also be a single comma-delimited string, which is more friendly
for appsettings.json configuration:
{
"webhookIpAllowList": "10.0.0.0/8, 172.16.0.0-172.16.255.255, 192.168.*.*"
}
Allow takes precedence when both are set: a request must match the allow list AND not match the deny list to pass. If only the allow list is set the request must match it; if only the deny list is set every non-matching IP passes.
Curated publisher presets (KnownPublisherCidrs)
For known publishers, name a preset instead of pasting CIDRs into every flow:
Inputs["webhookIpAllowListPreset"] = "github";
Presets bundled with the library:
| Preset | What it covers |
|---|---|
github |
GitHub published webhook ranges (api.github.com/meta — 4 IPv4 + 2 IPv6) |
stripe |
Stripe webhook source IPs |
shopify |
Shopify partner-published ranges |
twilio |
Twilio "Network Connectivity" ranges |
square |
Square developer-docs ranges |
atlassian / bitbucket |
Atlassian Cloud / Bitbucket Cloud (ip-ranges.atlassian.com) |
slack |
Slack outbound IP ranges |
mailgun |
Mailgun control-panel IPs |
zoom |
Zoom marketplace publisher ranges |
local / localhost / private |
RFC 1918 private + loopback (IPv4 + IPv6 + link-local) — for dev environments |
Use multiple presets in one flow with webhookIpAllowListPresets (plural):
// Array form
Inputs["webhookIpAllowListPresets"] = new[] { "github", "local" };
// Or comma-delimited string (appsettings.json friendly)
Inputs["webhookIpAllowListPresets"] = "github,stripe,local";
The plural form merges with the singular webhookIpAllowListPreset and the
explicit webhookIpAllowList — all three sources combine into a single
matcher. Use a custom array for environment-specific deltas; use presets for
the well-known partner ranges.
Caveat. Presets are point-in-time snapshots of each publisher's documentation. They drift. For production lock-down treat them as a defensive baseline, then verify against the publisher's current authoritative IP-range page before relying on them solo. Combine with an explicit list when you need newer / extra ranges.
Reverse-proxy / X-Forwarded-For
WebhookSecurityOptions.ForwardedHeaderDepth controls how deep into the
XFF chain the dashboard trusts:
opts.UseWebhookSecurity(sec => sec.UseForwardedHeaders(depth: 1));
0(default) — useHttpContext.Connection.RemoteIpAddressdirectly. Only trusts the immediate socket peer.1— trust 1 reverse-proxy hop; read the second-from-last entry inX-Forwarded-Foras the client.N— trust N hops. Never set this higher than the actual number of proxies you control: every extra hop is an attacker-controlled IP if the client itself can spoof the header.
Body size cap
WebhookSecurityOptions.MaxBodyBytes (default 1 MiB) is enforced via
WebhookRequestBuffer.ReadAsync before JSON parsing. Oversized requests
return 413 Payload Too Large and emit EventId 4004.
DLQ + dashboard surface
Every accepted and rejected delivery is persisted to
IWebhookRejectionStore (in-memory ring buffer, 1 000 entries by default,
or Sql/Postgres for long retention). The dashboard exposes:
GET /flows/api/webhooks/recent?flowId=&reason=&rejectedOnly=&skip=&take=— listing with filters.GET /flows/api/webhooks/stats?hours=24— counts by reason for the configured look-back window.- A "Webhooks" tab in the SPA renders both as a recent-deliveries table with reason chips and a 24-hour reason histogram.
Observability
Counters and histograms (see Observability):
webhook_received_total{flow,result,scheme}webhook_rejected_total{flow,reason}webhook_body_byteswebhook_processing_ms{flow,result}
EventIds 4000–4099 reserved; 4000–4010 in use today (full list in Observability — EventIds).
The existing flow.webhook.receive activity gains tags
flow.webhook.scheme, flow.webhook.client_ip,
flow.webhook.replay_skew_ms, flow.webhook.rate_limit.retry_after_ms,
flow.webhook.result, and flow.webhook.reject_reason.
Custom scheme reference
When webhookSignatureScheme = "Custom" the verifier composes its
behaviour from the manifest fields below instead of looking up a
built-in spec in PartnerSchemeRegistry. Most users will never need
these — pick a built-in scheme first.
Signature fields
| Field | Meaning |
|---|---|
webhookSignatureHeader |
Header carrying the digest (e.g. X-MyApp-Signature). |
webhookSignatureAlgorithm |
sha1, sha256 (default), sha384, sha512. SHA-1 also requires AllowLegacySha1. |
webhookSignatureEncoding |
hexLower (default), hexUpper, base64, base64Url. |
webhookSignaturePrefix |
Prefix stripped from the header value before decoding (e.g. sha256=). |
webhookSignatureMultiValueDelimiter |
Multi-value separator (Stripe ,, etc.); enables key=value parsing. |
webhookSignatureKeyValueSeparator |
Key/value separator inside each segment; defaults to =. |
webhookSignatureValueKey |
Segment key whose value is the signature (Stripe v1). |
webhookHeaderValuePrefix |
Prefix on the whole header line, stripped before parsing (Microsoft Teams HMAC ). |
webhookAcceptMultipleSignatures |
When true, every matching segment is treated as a candidate. |
webhookAcceptedVersions |
Allowlist of segment keys (["v0", "v1"]); other segments ignored. |
webhookSignedPayloadStrategy |
RawBody (default), TimestampDotBody, ColonDelimited, UrlPlusSortedForm, UrlPlusBody, TimestampPlusToken, Custom. |
webhookSignedPayloadDelimiter |
Delimiter between timestamp and body for delimited strategies. |
webhookSignedPayloadVersion |
Version literal injected for ColonDelimited (e.g. Slack v0). |
webhookCustomStrategyName |
Name of a strategy registered through AddCustomSignatureStrategy(...); required when strategy = Custom. |
Timestamp fields
| Field | Meaning |
|---|---|
webhookTimestampHeader |
Header that carries the publisher timestamp when it is not embedded in the signature header. |
webhookTimestampValueKey |
Multi-value key whose value is the timestamp (Stripe t). |
webhookRequireTimestamp |
When true, missing timestamps fail closed. |
Replay fields
| Field | Meaning |
|---|---|
webhookReplayToleranceSeconds |
Maximum allowed clock skew. 0 (default) disables replay protection. |
webhookNonceHeader |
Header used for the nonce ledger (e.g. X-GitHub-Delivery); falls back to SHA-256 of timestamp \|\| body. |
Rate-limit fields
| Field | Meaning |
|---|---|
webhookRateLimitPermitsPerSecond |
Steady-state token-bucket rate. |
webhookRateLimitBurstSize |
Maximum token burst. |
webhookRateLimitPerIp |
When true, the bucket key is flowId\|clientIp instead of flowId. |
IP allow / deny fields
| Field | Meaning |
|---|---|
webhookIpAllowList |
Explicit list of CIDRs / ranges / wildcards / single addresses. |
webhookIpDenyList |
Same shape as the allow list; allow takes precedence when both are set. |
webhookIpAllowListPreset |
Single curated preset (github, stripe, …) drawn from KnownPublisherCidrs. |
webhookIpAllowListPresets |
Plural form; comma-string or array. Merges with the singular form and the explicit list. |
For exotic byte compositions, register a custom strategy in DI and
reference it from webhookCustomStrategyName:
opts.UseWebhookSecurity(sec =>
sec.AddCustomSignatureStrategy("myapp-canonical", ctx => /* byte[] */));
Inputs["webhookSignedPayloadStrategy"] = "Custom";
Inputs["webhookCustomStrategyName"] = "myapp-canonical";
Bring your own scheme
When the wire format doesn't fit any built-in scheme nor the Custom
manifest shape — asymmetric verification, KMS-backed digests, query-string
signature carriers — register a full IWebhookSignatureVerifier in DI and
reference it by name from the manifest.
public sealed class AcmeCorpVerifier : IWebhookSignatureVerifier
{
public WebhookSignatureResult Verify(WebhookSignatureContext context)
{
// Read context.Headers / context.Body and verify against your KMS,
// public key, etc. Return Success / SuccessWithRotation / Failure(reason).
return WebhookSignatureResult.Success;
}
}
builder.Services.AddWebhookSignatureVerifier<AcmeCorpVerifier>("AcmeCorp");
Inputs["webhookSignatureScheme"] = "AcmeCorp";
The pipeline resolves verifiers in this order: built-in WebhookSignatureScheme
enum match → DI-registered verifier with matching scheme name →
Custom manifest shape. Scheme names are case-insensitive; collisions
with built-in scheme names (or the literal "Custom") throw
ArgumentException at registration time.
Recommended rollout
- Ship
EnforcementMode = AuditwithwebhookSignatureSchemepopulated on every webhook trigger. Verify legitimate traffic in the dashboard "Webhooks" tab; expect no rejections in the histogram. - Add
webhookReplayToleranceSeconds = 300and IP allowlist / preset. Watch for skew-related rejections — clock-drifted publishers will surface here. - Add rate limit values that match your normal traffic envelope plus a reasonable burst.
- Flip to
EnforcementMode = Enforceonce the audit period has passed. - For multi-replica deployments, register Sql or Postgres backends
(
AddSqlServerWebhookHardening/AddPostgreSqlWebhookHardening) before flipping toEnforce— otherwise replay protection is per-replica only.