Skip to content
yutils

HMAC Webhook Verification — How Stripe, GitHub, and Slack Sign Requests

Why webhooks need HMAC signatures, how providers like Stripe and GitHub sign payloads, constant-time comparison, replay protection, and a step-by-step verifier.

~8 min read

A webhook is an HTTP request that an external service sends directly to your server when an event happens. Stripe announces a successful charge, GitHub announces a push, Slack delivers a slash command — all webhooks. The real problem is confirming the request actually came from that service. IP allowlisting breaks when IPs change; putting a token in the URL leaks through logs, proxies, and browser history. The standard answer is HMAC signing.

Why HMAC

HMAC (Hash-based Message Authentication Code) combines a shared secret with the message body through a hash function like SHA-256 to produce a short signature. Without the secret you cannot forge the signature; if the body changes by a single byte, the signature is entirely different.

  • Forgery resistance — an attacker who sends a fake payload cannot generate a valid signature without the secret.
  • Tamper detection — any modification to the body breaks the signature.
  • Lightweight — much faster than public-key crypto. You can verify every request at negligible cost.

Want to compute a signature yourself? Drop your body and secret into HMAC Generator to see the SHA-256 output. To go the other way, HMAC Verify checks whether a received signature is one you could have produced with your secret.

Signature formats by provider

Stripe

Sent in the Stripe-Signature header:

t=1614265330,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd

The signed payload is <t>.<body> — timestamp + "." + raw JSON body. The secret is a per-endpoint whsec_... value.

GitHub

The X-Hub-Signature-256 header carries sha256=<hex>. The signed payload is the raw request body. The secret is whatever you configured for that webhook.

Slack

Two headers: X-Slack-Signature and X-Slack-Request-Timestamp. The signed payload is v0:<timestamp>:<body>. The signature has a v0=<hex> prefix.

Common pattern: the timestamp is part of the signed payload (Stripe, Slack). Without it the same valid request can be replayed forever.

Verification — step by step

  1. Preserve raw body — if your framework auto-parses the body, whitespace and key order change and the signature breaks. With Express, use express.raw() or capture req.rawBody.
  2. Parse the signature header — for Stripe, split on , and pull out t and v1.
  3. Validate the timestamp — reject if the difference from now is greater than 5 minutes (replay protection plus clock skew).
  4. Compute the expected signature — combine secret and body in the provider's format, then HMAC-SHA256.
  5. Constant-time compare — never use a === b; it leaks length information through timing. Use crypto.timingSafeEqual in Node, hmac.compare_digest in Python, etc.

Node.js example — Stripe style

import {createHmac, timingSafeEqual} from "crypto";

function verifyStripeSignature(body, sigHeader, secret) {
  // 1. parse header
  const parts = Object.fromEntries(
    sigHeader.split(",").map((kv) => kv.split("="))
  );
  const timestamp = parts.t;
  const signature = parts.v1;
  if (!timestamp || !signature) return false;

  // 2. timestamp within 5 minutes
  const now = Math.floor(Date.now() / 1000);
  if (Math.abs(now - Number(timestamp)) > 300) return false;

  // 3. expected signature
  const signedPayload = `${timestamp}.${body}`;
  const expected = createHmac("sha256", secret)
    .update(signedPayload)
    .digest("hex");

  // 4. constant-time compare
  const a = Buffer.from(expected, "hex");
  const b = Buffer.from(signature, "hex");
  return a.length === b.length && timingSafeEqual(a, b);
}

Pass the body as a string — if you JSON.parse and re-stringify, whitespace and key order change and the signature breaks.

Why constant-time compare?

A regular string comparison short-circuits at the first differing character. An attacker who can measure response time can flip one character at a time and watch where the timing changes — effectively reading the signature. A constant-time function always inspects every byte regardless of where the mismatch is.

  • Node: crypto.timingSafeEqual(a, b)
  • Python: hmac.compare_digest(a, b)
  • Go: subtle.ConstantTimeCompare(a, b)
  • Ruby: OpenSSL.fixed_length_secure_compare

Common pitfalls

1. Verifying after JSON parsing

Most frameworks auto-parse the body into an object, destroying the raw bytes. Always verify first, then parse.

2. Missing timestamp check

If you verify only the signature, an attacker can replay a captured legitimate request a year later. Both Stripe and Slack require a timestamp window for this reason.

3. URL path not included

If only the body is signed, the same body can be redirected to a different endpoint (cross-endpoint replay). Some APIs include the path and method in the signed payload to prevent this.

4. Leaked secret

Webhook secrets committed to GitHub leak instantly. Store them in environment variables or a secret manager. If exposed, rotate immediately.

5. Plain === comparison

See above on timing attacks. Always use a constant-time helper — the libraries are fast enough that there is no excuse.

SHA-256 vs SHA-1 — which hash?

SHA-1 has been broken since the 2017 SHAttered collision; do not use it for security. GitHub sends both v1 X-Hub-Signature (SHA-1) and v2 X-Hub-Signature-256 (SHA-256), but every new implementation should verify only SHA-256. Compare the two side by side in SHA Hash.

Simulate with these tools

  • HMAC Generator — body + secret → HMAC-SHA256 signature. Pre-compute what a provider should send.
  • HMAC Verify — given a received signature, check whether your secret could have produced it. Uses constant-time comparison; everything runs locally so the secret never leaves your browser.
  • SHA Hash — see the underlying hash alone. Useful for understanding HMAC internals (hash + key padding).

Summary

  • Webhook authenticity is the core problem — the standard answer is HMAC-SHA256.
  • Include the timestamp in the signed payload and reject anything outside a 5-minute window — replay defense.
  • Sign the raw body; watch out for framework auto-parsing.
  • Compare with a constant-time function.
  • Store secrets in env vars or a secret manager. Rotate immediately on exposure.
Back to guides