DevToys Web Pro iconDevToys Web ProBlog
Traduit avec LocalePack logoLocalePack
Évaluez-nous :
Essayez l’extension de navigateur :
← Back to Blog

HMAC Webhook Signatures: Verification, Timing Safety, and Replay Protection

11 min read

Webhooks arrive at your endpoint from IP addresses you do not control. Anyone on the internet can POST JSON to your URL. HMAC signatures solve this: the sender hashes the request body with a shared secret and sends the result in a header. Your server recomputes the same hash and rejects the request if the values differ. Without a valid signature, an attacker cannot forge a believable payload — they do not have the secret. Try computing HMAC values interactively with the HMAC Generator.

What HMAC Actually Is

HMAC stands for Hash-based Message Authentication Code. It is not encryption — it produces a fixed-length digest that proves both the integrity of a message and that the sender knew the shared secret. The output cannot be reversed to recover the secret or the original message.

The construction is defined in RFC 2104:

HMAC(K, m) = H((K' XOR opad) || H((K' XOR ipad) || m))

where:
  H    = underlying hash function (e.g. SHA-256)
  K    = secret key (padded/hashed to block size K')
  m    = message (request body)
  ipad = 0x36 repeated to block size
  opad = 0x5C repeated to block size

The double-hashing structure is the reason HMAC exists. A naive approach of hash(secret + body) is vulnerable to a length-extension attack: an attacker who sees the digest can append extra data to the message and compute a valid new digest without knowing the secret, because SHA-256 (and MD5, SHA-1) expose internal state at the end of their output. HMAC's nested construction prevents this entirely.

For a deeper comparison of underlying hash functions, see Hashing Algorithms Compared: MD5, SHA-1, SHA-256, SHA-512, BLAKE2. For the distinction between hashing, encryption, and password hashing, see Hashes, Encryption, and Password Hashing: What is the Difference?.

The Webhook Signature Flow

Every HMAC webhook follows the same three-step protocol:

StepWhoWhat happens
1. SignSenderComputes HMAC-SHA256(secret, raw_body), encodes as hex or base64, puts it in a request header
2. TransmitNetworkHTTP POST with body + signature header. TLS in transit, but the signature is independent of TLS.
3. VerifyReceiverReads the raw body (before any JSON parsing), recomputes HMAC with the same secret, compares with timing-safe equality

The key constraint: the receiver must hash the raw bytes that arrived over the wire, not a re-serialized version. Once you call JSON.parse(body) and re-stringify, whitespace and key order may differ, and the signature will not match.

Real-World Header Formats

Each major webhook provider uses HMAC-SHA256 but sends the signature in a slightly different header format. You need to handle each one specifically.

Stripe

Stripe's Stripe-Signature header contains a timestamp and one or more versioned signatures, comma-separated:

Stripe-Signature: t=1713571200,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a05bd539ba74379a9259221

The signed payload is not just the body — Stripe prepends the timestamp: `${t}.${rawBody}`. This is how Stripe implements replay protection at the signature level. Multiple v1= entries appear during key rotation — you verify against all of them and accept if any matches.

GitHub

GitHub uses X-Hub-Signature-256 with the digest prefixed by the algorithm name:

X-Hub-Signature-256: sha256=b94f7e3a9c2d1f6e8a5b0c4d7f2e1a3b9c8d5f4e2a7b0c3d6f9e1a4b8c2d5f7e

Strip the sha256= prefix before comparing. GitHub also sends the older X-Hub-Signature (HMAC-SHA1) for backwards compatibility, but you should always prefer the SHA-256 version.

Slack

Slack sends the signature and a separate timestamp header:

X-Slack-Signature: v0=a2fb4aa1fa43c6e83b4d9f8c7a5b2e1d9f3c6a8b0e4d7f2a5c8b1e4f7d0a3c6
X-Slack-Request-Timestamp: 1713571200

Slack's signed string is `v0:${timestamp}:${rawBody}`, and the signature is prefixed with v0=. The timestamp is checked separately — requests older than 5 minutes are rejected before even verifying the HMAC.

Timing-Safe Comparison

Using === or == to compare two strings leaks timing information. JavaScript's === stops comparing at the first mismatched character. An attacker who can send many requests and measure response times can determine, bit by bit, how many leading characters of their forged signature are correct. This is a timing oracle attack.

The fix is a constant-time comparison that always examines every byte regardless of where the first difference is:

LanguageSafe comparison function
Node.jscrypto.timingSafeEqual(Buffer.from(a), Buffer.from(b))
Pythonhmac.compare_digest(a, b)
Gosubtle.ConstantTimeCompare([]byte(a), []byte(b))
PHPhash_equals($a, $b)
RubyActiveSupport::SecurityUtils.secure_compare(a, b)

One caveat: crypto.timingSafeEqual in Node.js throws if the two buffers have different lengths. Always check lengths first — but do so with a comparison that does not short-circuit on mismatch: a.length === b.length && crypto.timingSafeEqual(...). The length check itself is not a timing risk because an attacker already knows the expected digest length (it is fixed for a given algorithm: 64 hex characters for HMAC-SHA256).

Step-by-Step: Verifying a Stripe Webhook in Node.js

The following example shows raw verification without the Stripe SDK, so you can see every step. In production you would normally use stripe.webhooks.constructEvent(), which wraps this same logic.

import crypto from 'node:crypto';

const STRIPE_WEBHOOK_SECRET = process.env.STRIPE_WEBHOOK_SECRET; // whsec_...
const TOLERANCE_SECONDS = 300; // 5 minutes

export function verifyStripeWebhook(rawBody, signatureHeader) {
  // 1. Parse the Stripe-Signature header
  // Format: t=<timestamp>,v1=<hex_digest>[,v1=<hex_digest>...]
  const parts = Object.fromEntries(
    signatureHeader.split(',').map((part) => part.split('=', 2))
  );
  const timestamp = parts['t'];
  const signatures = signatureHeader
    .split(',')
    .filter((p) => p.startsWith('v1='))
    .map((p) => p.slice(3));

  if (!timestamp || signatures.length === 0) {
    throw new Error('Invalid Stripe-Signature header');
  }

  // 2. Reject stale requests (replay protection)
  const age = Math.floor(Date.now() / 1000) - parseInt(timestamp, 10);
  if (age > TOLERANCE_SECONDS) {
    throw new Error(`Webhook timestamp too old: ${age}s`);
  }

  // 3. Compute the expected signature
  // Stripe signs: "<timestamp>.<rawBody>"
  const signedPayload = `${timestamp}.${rawBody}`;
  const expected = crypto
    .createHmac('sha256', STRIPE_WEBHOOK_SECRET)
    .update(signedPayload, 'utf8')
    .digest('hex');

  // 4. Compare with timing-safe equality against all provided v1 signatures
  const expectedBuf = Buffer.from(expected, 'hex');
  const matched = signatures.some((sig) => {
    // Only compare if lengths match (both are HMAC-SHA256 = 32 bytes = 64 hex chars)
    if (sig.length !== expected.length) return false;
    return crypto.timingSafeEqual(expectedBuf, Buffer.from(sig, 'hex'));
  });

  if (!matched) {
    throw new Error('Stripe webhook signature mismatch');
  }
}

// Usage in an Express route:
// app.post('/webhooks/stripe', express.raw({ type: 'application/json' }), (req, res) => {
//   verifyStripeWebhook(req.body, req.headers['stripe-signature']);
//   const event = JSON.parse(req.body);
//   // handle event...
//   res.sendStatus(200);
// });

The express.raw() middleware is critical. If you use express.json() before your webhook route, the body has already been parsed and re-serialized, breaking the signature. Always read the raw bytes.

Replay Protection

A valid signature proves the request came from the legitimate sender — but it does not prove it is fresh. An attacker who captures a valid signed request can replay it hours or days later. The two standard defenses are:

  • Timestamp tolerance: The sender includes a Unix timestamp in the signed payload (as Stripe and Slack do). The receiver rejects requests where |now - timestamp| > 5 minutes. This limits the replay window to the tolerance duration. Your server clock must be synchronized with NTP.
  • Nonce caching: The sender includes a unique nonce in the signed payload. The receiver stores seen nonces in a cache (Redis, memcached) with a TTL matching the tolerance window. Any request whose nonce is already in the cache is rejected. This gives exactly-once delivery semantics within the window.

Most webhook providers implement only the timestamp approach. If your system requires exactly-once processing (e.g. financial transactions), add your own nonce cache on top.

Key Rotation

When you rotate a webhook secret, there is a window where the old secret is no longer trusted but in-flight requests were signed with it. The safe rotation procedure is:

  1. Generate a new secret and add it to your verification logic alongside the old one.
  2. Update the sender to sign with the new secret.
  3. Wait for in-flight requests to drain (typically longer than your timestamp tolerance, e.g. 10 minutes).
  4. Remove the old secret from your verification logic.

Stripe makes this explicit: during a rollover, the Stripe-Signature header may contain multiple v1= entries signed with different secrets. Your verifier should try each active secret and accept if any produces a matching digest.

// Verifying against multiple active secrets during rotation
function verifyWithAnySecret(rawBody, expectedDigest, secrets) {
  return secrets.some((secret) => {
    const digest = crypto
      .createHmac('sha256', secret)
      .update(rawBody, 'utf8')
      .digest('hex');
    if (digest.length !== expectedDigest.length) return false;
    return crypto.timingSafeEqual(
      Buffer.from(digest, 'hex'),
      Buffer.from(expectedDigest, 'hex')
    );
  });
}

Common Pitfalls

  • Parsing before verifying: Any middleware that reads and re-encodes the body (JSON parsers, body compressors) will silently invalidate the signature. Mount your raw-body reader before all other middleware on webhook routes.
  • Character encoding mismatch: The HMAC input must be the exact byte sequence the sender hashed. If the sender used UTF-8 and you read the body as Latin-1, the digests will differ for any non-ASCII character. Always use UTF-8.
  • Different algorithms across providers: GitHub defaults to HMAC-SHA1 on its older X-Hub-Signature header and HMAC-SHA256 on X-Hub-Signature-256. Stripe uses HMAC-SHA256. Verify which algorithm each provider documents and hardcode it — do not infer it from the header.
  • Hex vs base64 output encoding: Stripe and GitHub send hex-encoded digests. Some providers send base64. Decode to raw bytes before the timing-safe comparison, or encode both sides to the same string format before comparing. Mixing encodings is a common source of false negatives.
  • Forgetting the prefix: GitHub's header value is sha256=<hex>, not just the hex digest. Strip the prefix before comparison. Slack's is v0=<hex>. Read each provider's documentation carefully.
  • Signed payload differs from raw body: Stripe signs timestamp + "." + body. Slack signs "v0:" + timestamp + ":" + body. If you hash only the body, verification will always fail even with the correct secret.

Compute HMAC-SHA256 digests and experiment with different keys and messages directly in your browser with the HMAC Generator — no data leaves your machine.