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 endpoint caps the request body before anything else,
then the pipeline runs four gates in order:
BodySizeCap (endpoint) → IpAllowlist → RateLimit → SignatureVerify → ReplayCheck → Dispatch
Idempotency dedupe is not a pipeline gate — it runs later, inside
FlowOrchestratorEngine.TriggerAsync, after the pipeline has accepted the
delivery.
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 200 (accept) — except an oversized body, which is capped before the pipeline and still returns 413 | 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 16 built-in schemes in PartnerSchemeRegistry (14 publishers plus the
Generic catch-all and GitHubLegacy), 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 15 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 and GitHubLegacy are the two
built-in schemes that require AllowLegacySha1 = true (both sign with HMAC-SHA1).
Inputs = new Dictionary<string, object?>
{
["webhookSlug"] = "twilio-sms",
["webhookHmacKey"] = "<Auth Token from Twilio console>",
["webhookSignatureScheme"] = "Twilio",
}
Square, Zoom, Linear, Dropbox, Calendly, Bitbucket, Atlassian, MicrosoftTeams
Same shape — set webhookSignatureScheme to the exact enum name
("MicrosoftTeams", no space). Names are matched case-insensitively but must
otherwise be exact: an unrecognised name resolves to no spec and the signature
gate is skipped silently, with no error. 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 emit EventId 4003 WebhookRateLimited; the retry-after hint is
not sent as a Retry-After header — it is exposed on the
flow.webhook.rate_limit.retry_after_ms span tag.
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 and before the security
pipeline runs, so the cap applies in every enforcement mode including Audit.
Oversized requests return 413 Payload Too Large. (EventId 4004 is reserved for
this case but is not currently emitted.)
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–4011 allocated today (4000, 4004, 4006 and 4009 are reserved but not currently emitted). 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";
Register a named scheme without writing a verifier
When the wire format fits the built-in HMAC verifier but the publisher isn't in
PartnerSchemeRegistry, register a named spec once at startup instead of
repeating the Custom field set on every flow:
builder.Services.AddFlowDashboard(opts => opts.UseWebhookSecurity(sec =>
sec.RegisterScheme("AcmePartner", new WebhookSignatureSpec
{
HeaderName = "X-Acme-Signature",
Algorithm = HmacAlgorithm.Sha256,
Encoding = SignatureEncoding.HexLower,
Prefix = "sha256=",
SignedPayloadStrategy = SignedPayloadStrategy.RawBody,
})));
Inputs["webhookSignatureScheme"] = "AcmePartner";
Registered names are matched case-insensitively and are checked before the
built-in enum, so registering a name that matches a built-in scheme shadows it.
The specs live on WebhookSecurityOptions.CustomSchemes.
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 in this order: DI-registered verifier
(AddWebhookSignatureVerifier<T>) → operator-registered spec (RegisterScheme)
→ built-in WebhookSignatureScheme enum match → Custom manifest shape. Scheme
names are case-insensitive; DI names can never shadow a built-in because
collisions with a built-in scheme name (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.