Safeguard
Application Security

Verifying webhook signatures correctly

Stripe gives you a 5-minute replay window and GitHub a raw-body HMAC — but most outages trace back to one bug: verifying JSON after it's been re-serialized.

Safeguard Research Team
Research
7 min read

GitHub's webhook docs are explicit about the header that matters: X-Hub-Signature-256, an HMAC-SHA256 digest of the raw request body, hex-encoded and prefixed with sha256=. The older X-Hub-Signature, an HMAC-SHA1 digest, still ships alongside it for backward compatibility, and it's the one careless integrations copy from a five-year-old Stack Overflow answer because it comes first alphabetically in the payload headers. Stripe's Stripe-Signature header goes a step further, packing a Unix timestamp and one or more v1= signatures into a single comma-separated value, computed over "{timestamp}.{raw_body}" — and Stripe's own SDKs reject anything older than a 5-minute tolerance by default. Both schemes exist to answer the same two questions: did this request really come from the provider, and could an attacker replay a captured, legitimately-signed request later? Getting the mechanics wrong is not a hypothetical risk — it's a routine one, because the two most common implementation mistakes (re-serializing JSON before hashing, and comparing signatures with ==) both look correct in a five-minute manual test and both fail silently in ways that are easy to miss until an audit or an incident forces the question. This post walks through what correct verification actually requires, provider by provider.

Why does the raw request body matter so much?

It matters because HMAC is computed over exact bytes, not over the logical content of a JSON object. If your framework parses the incoming body into a dict or object before your signature-check code runs, and you re-serialize that object to bytes for hashing, you are almost certainly hashing something different from what the provider originally signed — key order can change, whitespace can change, and numeric formatting can change (1.0 vs 1). The fix is structural, not cryptographic: capture the raw body as bytes at the earliest point in your request pipeline, before any JSON-parsing middleware touches it, and hash exactly that buffer. Safeguard's own Guard webhook docs call this out directly: "Always verify against the raw request body, before any JSON parsing or re-serialization." Frameworks that eagerly parse bodies (many Express/body-parser or Django REST setups) require an explicit raw-body capture route or middleware exemption for webhook endpoints specifically — you cannot bolt this on after the fact once the body has already been consumed.

Why is a naive string comparison a security bug, not just a style nit?

Because == (or .equals(), or string equality in most languages) short-circuits on the first mismatched byte, and that short-circuit takes measurably less time than a full comparison. An attacker who can send repeated forged requests and measure response latency can use that timing difference to recover a valid signature one byte at a time — a textbook timing side-channel. The defense is a constant-time comparison function that always examines every byte regardless of where the first mismatch occurs, and both major runtimes ship one for exactly this purpose: Python's hmac.compare_digest and Node's crypto.timingSafeEqual. Neither is optional hardening — they're the documented, correct way to compare a computed HMAC against a received one, and every provider's own reference implementation uses the equivalent in its language. If your verification code was written from scratch instead of copied from a provider's sample, checking this one line is worth the five minutes it takes.

How does replay protection actually work, and why isn't a timestamp header enough on its own?

A bare timestamp header is not enough because nothing stops an attacker who intercepts a valid, signed request from resending it unmodified within whatever window your server considers "recent" — the header value itself isn't cryptographically tied to anything. The fix both GitHub-style and Stripe-style schemes converge on is binding the timestamp into the signed payload itself, not just attaching it as a sibling header: Stripe hashes "{timestamp}.{body}", and Safeguard's platform-wide webhook signature (X-Safeguard-Signature: v1,t=<unix>,s=<hex hmac>) is computed the same way, over "{ts}." + body. Because the timestamp is inside the hashed material, an attacker can't strip or alter it without invalidating the signature — the only thing they can do is replay the exact original request before your tolerance window closes. Stripe's SDKs default to a 5-minute tolerance for that reason; Safeguard's platform webhooks use the same 5-minute window. A shorter window shrinks the replay opportunity further but raises false rejections from legitimate clock drift, so 5 minutes is a reasonable default rather than an arbitrary one.

Do all providers use the same header format, and can I share verification code between them?

No, and assuming you can is one of the more common integration bugs once a team has more than one webhook source wired up. GitHub and Stripe both use HMAC-SHA256 over roughly the same idea — timestamp plus body, or body alone — but the header shape differs enough that a shared parser breaks silently: GitHub's X-Hub-Signature-256 is a single sha256=<hex> value with no embedded timestamp (replay protection there relies on delivery IDs and TLS rather than a signed timestamp), while Stripe packs a timestamp and one or more versioned signatures into one comma-delimited header. Safeguard runs two separate signing schemes internally for exactly this reason, and the docs warn against treating them as interchangeable: general platform webhooks sign X-Safeguard-Signature: v1,t=<unix>,s=<hex> as a single combined header, while Guard's alert webhooks split the same information across two headers — X-Safeguard-Signature: sha256=<hex> plus a separate X-Safeguard-Timestamp. The lesson generalizes: write one verification function per provider, parameterized by header shape, rather than one "generic HMAC checker" you assume will parse anything.

What does a correct, minimal verifier actually look like?

It's short, but every line does something specific. In Python, checking a Stripe-style combined header or Safeguard's platform-style header: parse out the timestamp and signature from the header, reject if the timestamp falls outside your tolerance window (catching integer-parse errors as a rejection too, not a crash), recompute the HMAC over f"{ts}." concatenated with the raw body bytes, and compare with hmac.compare_digest(expected, sig) — never expected == sig. In Node, the equivalent uses createHmac("sha256", secret).update(`${ts}.`).update(body).digest("hex") and timingSafeEqual(Buffer.from(expected), Buffer.from(sig)), being careful that both buffers passed to timingSafeEqual are the same length before calling it, since it throws (rather than safely returning false) on a length mismatch — wrap that comparison in a length check first. None of this requires a cryptography library beyond what ships in the standard library of either language; the complexity is entirely in getting the byte handling and timing discipline right, not in the algorithm itself.

How Safeguard helps

Safeguard's own webhook systems are built around the practices in this post rather than around a single shared shortcut, because a single shortcut is exactly what causes cross-system bugs: platform-wide events (findings, scans, remediation, attestations) sign with X-Safeguard-Signature: v1,t=<unix>,s=<hex> over a combined timestamp-and-body payload with a 5-minute replay tolerance, while Guard's alert webhooks — a separate system with its own retry schedule and event catalog — sign with a split X-Safeguard-Signature: sha256=<hex> plus X-Safeguard-Timestamp header, and the docs explicitly warn against reusing one system's verification code against the other. Both reference implementations in Safeguard's docs use hmac.compare_digest and timingSafeEqual rather than plain equality, and both call out raw-body verification as a requirement, not a suggestion. If you're auditing your own integrations against third-party webhook providers, Safeguard's source-code scanning is built around the same source-to-sink dataflow tracing that would need to catch these patterns — following untrusted input through to how it's compared and hashed — so treat static analysis as one more layer, not a replacement for reviewing your webhook verification code by hand before it ships to a repo that trusts inbound traffic.

Never miss an update

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