GoWin Tools
Tools
โ† HMAC Generator

HMAC Generator ยท 6 min read

Webhook Signing With HMAC: How Stripe, GitHub, and Shopify Do It

How major platforms sign webhook payloads with HMAC-SHA256, why timestamps matter, and how to verify signatures correctly without falling into common pitfalls.

A webhook is a POST request from a server you don't control to an endpoint you do. Without authentication, anyone who guesses the URL can forge events. The standard fix is HMAC: the sender signs the payload with a shared secret, and the receiver verifies the signature before trusting anything in the body. Stripe, GitHub, and Shopify all use the same primitive โ€” HMAC-SHA256 โ€” but with subtle differences that matter when you write the verifier.

Why HMAC and Not a Plain Hash

A plain SHA-256 of the payload proves the bytes weren't corrupted in transit, but anyone can compute it. HMAC mixes a secret key into the hash, so only parties who know the key can produce a valid signature. RFC 2104 defines HMAC as H(key XOR opad || H(key XOR ipad || message)) โ€” two nested hashes with key-derived padding. The construction is provably secure even if the underlying hash has length-extension issues, which is why HMAC-SHA256 is preferred over a naive SHA256(secret || payload).

Stripe: Timestamp + Signed Payload

Stripe sends a header that looks like this:

Stripe-Signature: t=1614265330,v1=5257a869e7...,v0=...

The signed string is `${t}.${rawBody}` โ€” the timestamp, a literal dot, and the raw request body. You compute HMAC-SHA256 over that string with your webhook secret, then compare the hex digest to v1. The timestamp lets you reject replays: if t is more than five minutes old, drop the request even if the signature checks out.

The trap here is raw body. If your framework parses JSON before you compute the HMAC, you'll re-serialise it with different whitespace and the signature will never match. Capture the bytes as they arrive on the wire.

GitHub: Just the Body

GitHub sends X-Hub-Signature-256: sha256=... โ€” a hex digest of HMAC-SHA256 over the raw body, with no timestamp prefix. Simpler, but it means GitHub webhooks have no built-in replay protection. If a signed payload leaks (in logs, in a proxy cache), an attacker can replay it indefinitely. In practice GitHub mitigates this by also including a unique X-GitHub-Delivery UUID per delivery โ€” you can dedupe on it server-side.

Shopify: Base64 Instead of Hex

Shopify is identical to GitHub in structure but encodes the digest as Base64 in X-Shopify-Hmac-SHA256rather than hex. Easy to get wrong: if you compare a hex digest to a Base64 one, signatures will never match no matter how correct your key handling is. Always check the platform's docs for the encoding.

Constant-Time Comparison

Once you compute your expected signature, compare it to the received signature with a constant-time function โ€” crypto.timingSafeEqual in Node, hmac.compare_digest in Python, subtle.ConstantTimeComparein Go. A regular string equality check returns early on the first mismatching byte, leaking information about how many bytes are correct. Over enough requests an attacker can use this timing signal to forge a valid signature byte by byte. The attack is theoretical on most networks but trivial to defend against, so there's no excuse.

The Verification Recipe

For any HMAC-signed webhook, the steps are always:

  • Capture the raw request body before any parsing or middleware.
  • Reconstruct the exact string the sender signed (body alone, or timestamp.body, depending on the platform).
  • Compute HMAC-SHA256 with your webhook secret.
  • Encode the result the way the platform encodes it (hex or Base64).
  • Compare to the header value with a constant-time function.
  • If a timestamp is present, reject anything older than your tolerance window (Stripe defaults to 5 minutes).

Rotating Secrets

Webhook secrets leak the same way any credential does โ€” in committed config files, in CI logs, in a sloppy support thread. Most platforms let you have two valid secrets for a rotation window. During rotation, your verifier should accept either; once traffic drains from the old one, retire it. Stripe's dashboard lets you generate a new endpoint secret and keep the old one live for 24 hours; GitHub and Shopify expect you to handle the overlap yourself.

What HMAC Doesn't Solve

HMAC verifies that whoever sent the request knows the secret. It doesn't encrypt the payload โ€” anything in the body is visible to TLS-terminating proxies and logging middleware. It doesn't prove freshness without a timestamp. And it doesn't protect against an attacker who already has the secret. For payloads that contain sensitive data, terminate TLS at your application, treat webhook secrets like any other credential, and rotate them on a schedule.

Webhook signing is one of those primitives that's easy to skip on day one and painful to retrofit on day 200. Bake it in early, copy the patterns the big platforms use, and you'll avoid the long tail of forged-event bug reports.

References

  1. Krawczyk, H., Bellare, M., & Canetti, R. (1997). RFC 2104: HMAC: Keyed-Hashing for Message Authentication. Internet Engineering Task Force.
  2. Stripe. (2024). Verify webhook signatures โ€” Stripe API documentation.
  3. GitHub. (2024). Securing your webhooks โ€” GitHub Developer documentation.
  4. Shopify. (2024). Verify a webhook โ€” Shopify Partner documentation.
  5. National Institute of Standards and Technology. (2008). FIPS 198-1: The Keyed-Hash Message Authentication Code (HMAC).