Verifying webhook signatures with HMAC
Why providers sign deliveries, how HMAC verification works on the raw body, and how to stop replayed requests.
8 min read · Updated 24 October 2026
A webhook endpoint is a public URL. Anyone who discovers it can send a request that looks exactly like a real event: a fake "payment succeeded", a forged "user deleted". Signatures are how a receiver tells genuine deliveries from forgeries. Verifying them is not optional for anything that changes data.
How HMAC signatures work
Most providers use HMAC, a keyed hash. When you create the webhook, the provider gives you a signing secret. For every delivery, the provider computes HMAC-SHA256(secret, message), where the message is the request body (often combined with a timestamp), and puts the result in a header. You compute the same value with your copy of the secret. If the values match, two things are proven:
- The sender knows the secret, so it is the provider.
- The body was not changed in transit, because any change produces a different signature.
HMAC does not encrypt anything. The payload is still readable by anyone who can see the request, which is why webhooks must also use HTTPS.
A typical scheme
Schemes differ in details, so always follow your provider's documentation. A common pattern signs a timestamp together with the body and sends both in a header:
X-Example-Signature: t=1729778527,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bdHere the signed message is "1729778527" + "." + rawBody and v1 is the hex-encoded HMAC-SHA256 of that message. Other providers sign the body alone, prefix the value with the algorithm (sha256=…), or encode it in base64 instead of hex.
Verifying in Node.js
This example verifies the scheme above. The structure carries over to any language.
import { createHmac, timingSafeEqual } from "node:crypto";
const TOLERANCE_SECONDS = 300;
export function verifyWebhook(rawBody, header, secret, now = Date.now()) {
// 1. Parse "t=...,v1=..." into its parts.
const parts = Object.fromEntries(
header.split(",").map((kv) => kv.split("=", 2).map((s) => s.trim())),
);
const timestamp = Number(parts.t);
const received = parts.v1;
if (!Number.isInteger(timestamp) || !received) return false;
// 2. Reject old (or far-future) deliveries to limit replay attacks.
if (Math.abs(now / 1000 - timestamp) > TOLERANCE_SECONDS) return false;
// 3. Compute the expected signature over the RAW body bytes.
const expected = createHmac("sha256", secret)
.update(`${timestamp}.`)
.update(rawBody) // a Buffer, exactly as received
.digest("hex");
// 4. Compare in constant time. Lengths must match first.
const a = Buffer.from(expected, "utf8");
const b = Buffer.from(received, "utf8");
return a.length === b.length && timingSafeEqual(a, b);
}The four rules that matter
1. Verify the raw bytes
The signature covers the body exactly as sent. If your framework parses JSON first and you call JSON.stringify on the result, spacing, key order, escaped characters and number formatting can all change. Configure your framework to give you the raw body for the webhook route (for example, a raw body parser on that route only) and verify before parsing.
2. Compare in constant time
A normal string comparison returns as soon as it finds a different character. In principle an attacker can measure those tiny timing differences to guess a valid signature one character at a time. Use your language's constant-time comparison: crypto.timingSafeEqual in Node.js, hmac.compare_digest in Python, hmac.Equal in Go, hash_equals in PHP.
3. Limit the replay window
A valid signature stays valid forever. If an attacker captures one genuine delivery, they can resend it later and it will verify. Including a timestamp in the signed message and rejecting old timestamps (five minutes is a common tolerance) closes most of that window. Recording processed event IDs and refusing repeats closes the rest, and you need that for idempotency anyway.
4. Keep the secret secret
Store the signing secret in your secrets manager or environment, not in source control. Use a different secret per environment. When you rotate it, many providers sign with both the old and new secret for a period; accept either signature during the overlap, then remove the old one.
Other schemes you may see
- Public-key signatures. Some providers sign with a private key (for example Ed25519 or RSA) and publish the public key. You verify without sharing a secret, which is useful when many parties consume the same events.
- Shared tokens. A static token in a header or query string. It proves the sender knows the token but does not protect the body from modification and is exposed in logs if it is in the URL. Prefer HMAC when you have the choice.
- Mutual TLS. The sender presents a client certificate. Strong, but more work to operate, and usually found in enterprise or financial integrations.
Testing your verification code
The hardest part of testing signatures is getting a real signed request. Register a URL from the inspector with the provider, trigger an event, then use Copy as cURL to send the identical bytes and headers to your local handler. Because the timestamp is old by then, temporarily widen the tolerance in a test, or use the provider's test tooling to generate fresh deliveries. Also test the failure paths: change one character of the body, use a wrong secret, and confirm that each one is rejected.