Safeguard
Best Practices

Webhook security best practices: HMAC signing, replay protection, and IP allowlisting

Stripe gives webhook signatures a 5-minute tolerance window; GitHub signs with HMAC-SHA256. Here's how to build inbound and outbound webhooks that survive both.

Safeguard Research Team
Research
7 min read

A webhook endpoint is an HTTP listener you've told the internet to send POST requests to, and the moment you stand one up, anyone who guesses or leaks the URL can attempt to feed it forged events. GitHub ships X-Hub-Signature-256, an HMAC-SHA256 hex digest of the raw request body, alongside the weaker SHA-1-based X-Hub-Signature that it keeps only for backward compatibility. Stripe's webhook signing, documented since its Stripe-Signature header format was introduced, computes an HMAC-SHA256 over a string built as {timestamp}.{raw_body} and its official SDKs reject anything outside a default five-minute tolerance window. Between those two examples sits the entire defensive pattern most teams get only partly right: verifying who sent a payload, proving it hasn't been replayed, and constraining who's even allowed to try. This post walks through all three layers for both inbound webhooks you receive and outbound webhooks you send, using GitHub's and Stripe's public implementations as the reference points, and closes with how Safeguard's own webhook systems apply the same pattern.

Why isn't a shared secret in the URL enough?

A shared secret in the URL isn't enough because URLs get logged. Load balancers, reverse proxies, CDN edge logs, and even browser history (if anyone ever pastes the URL into a browser to "check" it) routinely capture full request URLs, including query strings — so a secret placed there leaks into infrastructure you don't control and can't easily rotate. It's also static: anyone who captures one request can replay it indefinitely, since nothing about the URL changes per-delivery. The fix both GitHub and Stripe converged on independently is to sign the payload itself with a secret that never appears in the URL or the body — instead it's used only locally, by both sender and receiver, to compute an HMAC that travels in a header. That HMAC is worthless to an attacker without the secret, and unlike a bearer token it doesn't grant access to anything if intercepted on its own — it only proves a specific payload was signed by someone who holds the key.

How does HMAC signature verification actually stop a forged payload?

HMAC (Hash-based Message Authentication Code) verification stops forgery because computing a valid signature requires the shared secret, which never travels over the wire. GitHub's implementation takes the raw request body, runs it through HMAC-SHA256 keyed with your webhook secret, and sends the hex digest in X-Hub-Signature-256 as sha256=<digest>; your server repeats the same computation and compares. Stripe's is similar but binds in a timestamp: HMAC-SHA256(secret, "{timestamp}.{raw_body}"), delivered as Stripe-Signature: t=<timestamp>,v1=<hmac>. The critical implementation detail both providers call out explicitly is to verify against the raw, unparsed request body — if your framework JSON-decodes and re-serializes the payload before you compute the HMAC, differences in key ordering or whitespace silently break a legitimately-signed request. The second critical detail is comparison method: use a constant-time comparison (hmac.compare_digest in Python, crypto.timingSafeEqual in Node) rather than ==, because naive string comparison exits early on the first mismatched byte, and that timing difference is measurable enough over many requests to let an attacker guess a valid signature byte by byte.

What does replay protection add that signature verification alone doesn't?

Signature verification alone proves a payload was signed by someone with the secret — it says nothing about when. If an attacker with network visibility (a compromised proxy, a logging pipeline, a leaked request capture) records one legitimately-signed delivery, they can resend that exact request as many times as they like and it will pass signature verification forever, because the HMAC never expires on its own. Stripe closes this by binding a timestamp into the signed string itself and giving its official libraries a default five-minute tolerance: if the t= value in Stripe-Signature is older than that when checked against wall-clock time, verification fails even though the HMAC is mathematically valid. GitHub's headers don't carry a timestamp by default, which is why GitHub's own docs recommend also tracking delivery IDs (X-GitHub-Delivery) to detect duplicates. The general lesson: a signature check without a bound time window or a deduplication key only proves authenticity at some point in the past, not that the request is happening now.

Why is IP allowlisting a secondary control, not a primary one?

IP allowlisting restricts which source addresses may reach your webhook endpoint at all, and both GitHub and Stripe publish the IP ranges their webhook infrastructure sends from specifically so customers can build this layer. It's useful defense-in-depth: it cuts off casual scanning and forged deliveries from addresses that could never be the real sender, and it reduces the attack surface that reaches your HMAC-checking code in the first place. But providers state plainly that these ranges can change without much notice as they scale infrastructure or migrate cloud regions, so an allowlist that isn't kept current will eventually reject legitimate deliveries — and IP addresses can, in principle, be spoofed or routed through infrastructure inside a published range that isn't actually the provider. Treat IP allowlisting as a rate-limiting and noise-reduction layer that sits in front of signature verification, never as a substitute for it. If your allowlist ever fails open (a misconfigured rule, a missed range update) you still want the HMAC check catching everything that gets through.

How does Safeguard apply this pattern to its own webhooks?

Safeguard runs two separate webhook systems that both implement this triad, deliberately with different header formats so they aren't confused with each other. The platform-wide events pipeline — covering findings, scans, remediation, attestations, and audit — signs every delivery with a single X-Safeguard-Signature: v1,t=<unix>,s=<hex hmac-sha256> header, computed as HMAC-SHA256(secret, "{ts}." + body) , and its documented reference verifier rejects any timestamp more than 300 seconds old before even checking the HMAC, matching Stripe's tolerance-window approach. It also assigns every event a stable X-Safeguard-Event-Id and guarantees at-least-once (not exactly-once) delivery with exponential backoff over 11 attempts across 24 hours plus a dead-letter queue, so consumers are expected to deduplicate by ID rather than assume single delivery. Guard, Safeguard's separate MCP-traffic monitoring product, has its own webhook system for alert firings that uses a distinct X-Safeguard-Signature: sha256=<hex> plus an independent X-Safeguard-Timestamp header, with a shorter four-attempt retry schedule and no DLQ. The two schemes are intentionally not interchangeable — verification code written for one will not validate the other's signatures.

What should an outbound webhook sender guarantee its recipients?

If your own service sends webhooks to customers, the same three controls apply in reverse, plus a few sender-side obligations. Sign every payload with a per-endpoint secret the customer generates or you generate and hand back exactly once — never log or re-display it afterward, since a secret that's recoverable from your own dashboard is only as strong as your dashboard's access controls. Include a timestamp in the signed string so recipients can enforce their own replay window, and document your exact concatenation order (timestamp + "." + body, or however you construct it) precisely, because an ambiguous spec is the single most common cause of "my signature never validates" support tickets. Publish your sending IP ranges if your infrastructure is stable enough to make that a reliable allowlist input, and update that documentation before you change egress infrastructure, not after. Finally, give customers a way to test their verification code against a real, freshly-signed payload — Safeguard's safeguard webhooks test command exists specifically so integrators can validate signature handling without waiting for a real state change to trigger a delivery.

Never miss an update

Weekly insights on software supply chain security, delivered to your inbox.