Webhooks are one of the most common patterns for real-time communication between services. When an event occurs in a third-party system, such as a payment completing in Stripe, a push to a GitHub repository, or a message posted in Slack, the service sends an HTTP POST request to a URL you specify. But how do you know the request genuinely came from the service it claims to be from? The answer is webhook signature verification using HMAC authentication. This guide explains the concept in depth, walks through real-world patterns from popular services, and provides code examples you can adapt for your own projects.
What Are Webhook Signatures?
A webhook signature is a cryptographic hash included in the webhook request, typically as an HTTP header. The sending service computes this hash using a shared secret (known only to the sender and the receiver) and the request body. When you receive the webhook, you recompute the hash using the same secret and the received body, then compare the two values. If they match, you know two things: the request genuinely came from the expected sender, and the request body has not been tampered with in transit.
Without signature verification, anyone who discovers your webhook endpoint URL could send forged requests. They could fake a payment success event, trigger unauthorized actions in your system, or inject malicious data. Signature verification is not optional; it is a fundamental security requirement for any production webhook handler.
How HMAC Works
HMAC stands for Hash-based Message Authentication Code. It is defined in RFC 2104 and combines a cryptographic hash function (such as SHA-256) with a secret key to produce a fixed-size output that serves as both an integrity check and an authentication tag.
The HMAC algorithm works as follows:
- The secret key is padded or hashed to match the hash function's block size (64 bytes for SHA-256).
- The padded key is XORed with a fixed "inner padding" constant (0x36 repeated) to produce the inner key.
- The inner key is prepended to the message, and the hash function is applied to produce an intermediate hash.
- The padded key is XORed with a fixed "outer padding" constant (0x5c repeated) to produce the outer key.
- The outer key is prepended to the intermediate hash, and the hash function is applied again to produce the final HMAC value.
This double-hashing construction makes HMAC resistant to length-extension attacks that affect plain hash functions. Even if an attacker knows the hash of a message, they cannot compute the HMAC without knowing the secret key.
In practice, you never implement HMAC yourself. Every programming language provides a standard library implementation. In Node.js, you use the crypto module. In Python, the hmac module. In Go, crypto/hmac.
Why You Must Verify Webhook Signatures
Let us be explicit about the attack scenarios that signature verification prevents:
- Request forgery — an attacker sends a crafted POST request to your webhook endpoint pretending to be Stripe, GitHub, or any other service. Without verification, your system processes it as legitimate.
- Replay attacks — an attacker intercepts a legitimate webhook request and re-sends it later. Some signature schemes include a timestamp to mitigate this (more on this below).
- Data tampering — a man-in-the-middle attacker modifies the request body while keeping the URL and headers intact. The signature check catches this because the hash of the modified body will not match.
- Privilege escalation — an attacker sends a webhook event that upgrades their account, confirms a payment they did not make, or grants permissions they should not have.
Stripe Webhook Signatures
Stripe uses a well-designed signature scheme that includes replay protection. Each webhook request includes a Stripe-Signature header with this format:
t=1614556800,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bdThe t value is a Unix timestamp indicating when Stripe generated the signature. The v1 value is the HMAC-SHA256 signature. To verify:
- Extract the timestamp and signature from the header.
- Construct the signed payload by concatenating the timestamp, a period character, and the raw request body:
`${timestamp}.${rawBody}`. - Compute the HMAC-SHA256 of this payload using your webhook signing secret.
- Compare your computed signature with the
v1value from the header. - Check that the timestamp is within an acceptable tolerance (Stripe recommends 5 minutes) to prevent replay attacks.
Here is the verification in Node.js:
const crypto = require("crypto");
function verifyStripeSignature(payload, header, secret) {
const parts = header.split(",");
const timestamp = parts
.find((p) => p.startsWith("t="))
?.slice(2);
const signature = parts
.find((p) => p.startsWith("v1="))
?.slice(3);
if (!timestamp || !signature) {
throw new Error("Invalid signature header format");
}
// Check timestamp tolerance (5 minutes)
const age = Math.floor(Date.now() / 1000) - parseInt(timestamp, 10);
if (age > 300) {
throw new Error("Webhook timestamp too old");
}
const signedPayload = `${timestamp}.${payload}`;
const expected = crypto
.createHmac("sha256", secret)
.update(signedPayload)
.digest("hex");
if (!crypto.timingSafeEqual(
Buffer.from(expected),
Buffer.from(signature)
)) {
throw new Error("Signature verification failed");
}
return true;
}GitHub Webhook Signatures
GitHub sends a X-Hub-Signature-256 header containing the HMAC-SHA256 of the request body, prefixed with sha256=:
sha256=d57c68ca6f92289e6987922ff26938930f6e66a2d161ef06abdf1859230aa23cGitHub's scheme is simpler than Stripe's because it does not include a timestamp component. Verification involves computing the HMAC-SHA256 of the raw request body using your webhook secret and comparing it with the header value:
const crypto = require("crypto");
function verifyGitHubSignature(payload, signatureHeader, secret) {
const expected = "sha256=" + crypto
.createHmac("sha256", secret)
.update(payload)
.digest("hex");
if (!crypto.timingSafeEqual(
Buffer.from(expected),
Buffer.from(signatureHeader)
)) {
throw new Error("Signature verification failed");
}
return true;
}GitHub also sends X-Hub-Signature (without the "256") using SHA-1, but SHA-1 is considered weak and you should always use the SHA-256 version.
Slack Webhook Signatures
Slack's signature scheme is similar to Stripe's. The X-Slack-Signature header contains a versioned signature, and the X-Slack-Request-Timestamp header provides the timestamp:
v0=a2114d57b48eac39b9ad189dd8316235a7b4a8d21a10bd27519666489c69b503The signed payload is constructed as: v0:timestamp:rawBody. Verification follows the same pattern:
const crypto = require("crypto");
function verifySlackSignature(body, timestamp, signature, secret) {
// Reject requests older than 5 minutes
const age = Math.floor(Date.now() / 1000) - parseInt(timestamp, 10);
if (age > 300) {
throw new Error("Request timestamp too old");
}
const sigBasestring = `v0:${timestamp}:${body}`;
const expected = "v0=" + crypto
.createHmac("sha256", secret)
.update(sigBasestring)
.digest("hex");
if (!crypto.timingSafeEqual(
Buffer.from(expected),
Buffer.from(signature)
)) {
throw new Error("Signature verification failed");
}
return true;
}Understanding Timing Attacks
You may have noticed every code example above uses crypto.timingSafeEqual() instead of a simple === comparison. This is not a minor detail; it is a critical security measure against timing attacks.
A standard string comparison (===) short-circuits: it returns false as soon as it finds the first mismatched character. This means the comparison takes longer when more characters match. An attacker can exploit this timing difference to guess the correct signature one character at a time.
Here is how the attack works in theory:
- The attacker sends a webhook with a random signature and measures the response time.
- They try all possible values for the first character. The attempt that takes slightly longer has the correct first character (because the comparison proceeded to the second character before failing).
- They repeat this process for each subsequent character, gradually reconstructing the valid signature.
In practice, network jitter makes this attack very difficult over the internet, but it is feasible in certain environments (same data center, low latency). The timingSafeEqual function always compares every byte regardless of where mismatches occur, making it immune to timing analysis.
In Python, the equivalent function is hmac.compare_digest(). In Go, use subtle.ConstantTimeCompare() from the crypto/subtle package. Never implement your own constant-time comparison.
Common Implementation Mistakes
- Parsing the body before verification — middleware that parses JSON before your verification code runs will modify the raw body. You need the exact bytes that were signed. In Express.js, use
express.raw({ type: 'application/json' })for your webhook route. - Using the wrong encoding — ensure you compute the HMAC on the raw bytes (typically UTF-8). Do not convert the body to a different encoding or stringify a parsed JSON object, as the resulting bytes may differ from the original.
- Ignoring the timestamp — services that include timestamps do so for a reason. Always check the timestamp tolerance. Accepting old signatures opens a window for replay attacks.
- Hardcoding the secret — webhook signing secrets must be stored in environment variables or a secrets manager, never in source code. Rotate them periodically and when team members leave.
- Using a simple equality check — as discussed above, always use a constant-time comparison function.
A Generic Verification Pattern
While each service has its own header names and payload construction rules, the core verification logic is universal. Here is a reusable pattern:
const crypto = require("crypto");
function verifyHmacSignature({
payload,
secret,
receivedSignature,
algorithm = "sha256",
encoding = "hex",
}) {
const computed = crypto
.createHmac(algorithm, secret)
.update(payload)
.digest(encoding);
const a = Buffer.from(computed);
const b = Buffer.from(receivedSignature);
if (a.length !== b.length) {
return false;
}
return crypto.timingSafeEqual(a, b);
}This function handles the cryptographic core. You then write thin wrappers for each service that extract headers, construct the signed payload, and call this function.
Testing Webhook Signature Verification
Testing your verification logic requires generating valid signatures. You can compute test signatures using the same HMAC function with a known secret and payload:
// Generate a test signature
const testSecret = "whsec_test123";
const testPayload = JSON.stringify({ event: "test", data: {} });
const testSignature = crypto
.createHmac("sha256", testSecret)
.update(testPayload)
.digest("hex");
// Use testSignature in your test HTTP requestWrite test cases for: valid signatures (should pass), invalid signatures (should reject), expired timestamps (should reject), missing headers (should reject), and tampered bodies (should reject). Each of these scenarios should produce a clear error message that helps with debugging in production.
Verify Webhook Signatures with PulpMiner
PulpMiner's Webhook Signature tool lets you compute and verify HMAC signatures directly in your browser. Paste your webhook payload and secret, select the algorithm, and instantly see the computed signature. This is invaluable for debugging webhook integrations and validating that your verification code produces the correct output.
