Webhook Signing & HMAC Verification Best Practices for Secure Delivery

Updated May 24, 202612 min read

Webhook signing HMAC verification best practices for secure delivery start with one rule: verify the exact raw request body before your app trusts the event. A secure receiver checks the signature, timestamp, key ID, replay state, and payload schema before running business logic.

This guide gives you a production-ready verification flow, copy-paste Node.js examples, replay protection, rotation guidance, and a checklist you can use during reviews.

Secure Delivery Verification Flow

Treat the signature as the gate in front of the rest of your webhook handler. Do not deserialize JSON, write to the database, call downstream APIs, or enqueue work until these checks pass.

  1. Read the raw request body bytes exactly as received.
  2. Extract the timestamp, key ID, and signature headers.
  3. Reject stale timestamps before doing expensive work.
  4. Look up the signing secret by key ID.
  5. Rebuild the signed payload using the provider's documented format.
  6. Compute HMAC-SHA256 and compare with a timing-safe function.
  7. Reject duplicate event IDs or nonces inside the replay window.
  8. Validate the payload schema and process idempotently.

Recommended Header Contract

If you control both sender and receiver, make the signing metadata explicit. The receiver should not need to guess the algorithm, key, or freshness window.

X-Webhook-Key-Id: whsec_2026_05
X-Webhook-Timestamp: 1789982604
X-Webhook-Signature: sha256=3e2f...a91b

The signed payload should include the timestamp and raw body, for example timestamp.rawBody. This binds freshness and content integrity to the same HMAC.

Node.js HMAC Verification Example

This verifier assumes your framework gives you a Buffer containing the raw body. In Express, use express.raw() for the webhook route. In Next.js route handlers, call await request.arrayBuffer() before parsing JSON.

import crypto from "node:crypto";

const MAX_AGE_SECONDS = 300;

interface WebhookHeaders {
  "x-webhook-signature"?: string;
  "x-webhook-timestamp"?: string;
  "x-webhook-key-id"?: string;
}

export function verifyWebhookSignature(
  rawBody: Buffer,
  headers: WebhookHeaders,
  getSecret: (keyId: string) => string | undefined,
): boolean {
  const signatureHeader = headers["x-webhook-signature"];
  const timestampHeader = headers["x-webhook-timestamp"];
  const keyId = headers["x-webhook-key-id"];

  if (!signatureHeader || !timestampHeader || !keyId) return false;

  const timestamp = Number(timestampHeader);
  if (!Number.isFinite(timestamp)) return false;

  const age = Math.abs(Date.now() / 1000 - timestamp);
  if (age > MAX_AGE_SECONDS) return false;

  const secret = getSecret(keyId);
  if (!secret) return false;

  const [, receivedHex] = signatureHeader.split("=", 2);
  if (!receivedHex) return false;

  const signedPayload = Buffer.concat([
    Buffer.from(`${timestampHeader}.`, "utf8"),
    rawBody,
  ]);

  const expectedHex = crypto
    .createHmac("sha256", secret)
    .update(signedPayload)
    .digest("hex");

  const received = Buffer.from(receivedHex, "hex");
  const expected = Buffer.from(expectedHex, "hex");

  if (received.length !== expected.length) return false;
  return crypto.timingSafeEqual(received, expected);
}

Replay Protection

A valid signature proves the payload was signed, but it does not prove the delivery is new. Replay protection closes that gap by combining timestamp checks with a short-lived event ID or nonce cache.

async function rejectReplay({
  eventId,
  timestamp,
  cache,
}: {
  eventId: string;
  timestamp: number;
  cache: { setIfAbsent(key: string, ttlSeconds: number): Promise<boolean> };
}) {
  const ageSeconds = Math.abs(Date.now() / 1000 - timestamp);
  if (ageSeconds > 300) return true;

  const firstSeen = await cache.setIfAbsent(`webhook:event:${eventId}`, 300);
  return !firstSeen;
}

Timestamp tolerance

Reject deliveries older than 5 minutes unless your provider documents a different retry signature model.

Idempotency key

Store the provider event ID with a unique constraint before mutating state.

Nonce cache

When no stable event ID exists, require a nonce and store it in Redis or another TTL cache.

Signing Secret Rotation

Secret rotation should be boring and observable. Use a key ID so the receiver can pick the right secret without trying every value, then keep a short grace window for retries signed with the previous key.

  • Store signing secrets in a secret manager, not in source code.
  • Issue every active secret with a stable kid value.
  • Accept current and previous secrets during the rotation overlap.
  • Log the key ID that verified each delivery so old-key traffic is visible.
  • Remove expired keys after retry traffic drains and alerts stay quiet.

Provider Signing Patterns

ProviderPatternImplementation note
StripeTimestamp plus HMAC-SHA256 in Stripe-SignatureUse official libraries when possible, keep the raw body untouched, and enforce timestamp tolerance.
GitHubHMAC-SHA256 in X-Hub-Signature-256Compute the digest over the raw payload and compare against the header value.
SlackVersion, timestamp, raw body, and HMAC-SHA256Build the base string from v0:timestamp:body and reject stale timestamps.

Always follow the provider's exact canonicalization rules. The delimiter, timestamp format, digest encoding, and header name differ across vendors.

Verification Checklist

Raw body is captured before JSON parsing.
Signature header is required and parsed strictly.
Only supported algorithms and versions are accepted.
Timestamp is signed and checked for freshness.
Timing-safe comparison is used for signatures.
Event IDs or nonces block duplicate processing.
Current and previous secrets are handled during rotation.
Payload schema is validated after signature verification.
Handlers are idempotent and return 2xx quickly.
Failures log reason codes without leaking secrets.

Test Signed Webhooks Before Production

Signature bugs usually come from framework body parsing, wrong secrets, stale clocks, or retry paths that bypass verification. Test happy-path signatures, modified payloads, stale timestamps, wrong key IDs, and duplicate event IDs before enabling production traffic.

Use Hooklistener to capture a real delivery, inspect the raw headers, replay it to your local handler, and compare how your app responds to valid and intentionally broken signatures.

FAQ

What is webhook signing?

Webhook signing is the practice of attaching a cryptographic signature to each delivery. The sender signs the raw request body, usually with HMAC-SHA256 and a shared secret. The receiver recomputes the signature before processing the event.

What is the most important HMAC verification best practice?

Verify the exact raw request body before JSON parsing or other transformations. Any middleware that changes whitespace, encoding, or field order can break the signature check and create false failures.

How do webhooks prevent replay attacks?

Include a timestamp in the signed payload, reject deliveries outside a short tolerance window, and store event IDs or nonces in a short-lived cache so the same signed request cannot be processed twice.

How should webhook signing secrets be rotated?

Use key IDs, keep current and previous secrets active during a short grace window, log which key verified each delivery, and remove the old key after retry traffic has drained.

Should I rely on IP allowlisting instead of signatures?

No. IP allowlisting is useful defense in depth, but HMAC signature verification should be the source of truth because it proves the payload was not modified and was signed with the shared secret.

Related Guides