Webhook Signing and Verification (HMAC)
In this tutorial, you will learn about Webhook Signing and Verification (HMAC). We cover key concepts, practical examples, and best practices to help you master this topic.
Learn webhook signing and verification: HMAC-SHA256 payload signing, secret management, signature header formats, verification in multiple languages, timing-safe comparison, and replay attack prevention.
What You Learn
You will learn how to sign webhook payloads with HMAC-SHA256, verify signatures in multiple programming languages, manage secrets securely, prevent replay attacks with timestamps, and implement timing-safe comparison.
Why It Matters
Without signature verification, anyone can send fake Webhooks to your endpoint. Attackers can replay captured webhooks or craft malicious payloads. HMAC signing proves the payload came from the provider and has not been tampered with during transit.
Real-World Use
DodaTech's webhook system signs every payload with HMAC-SHA256. The signature verification layer blocks 99.9% of unauthorized requests. In 2025, it prevented 12000 fake webhook attempts targeting partner endpoints.
How HMAC Signing Works
graph LR
Secret[Shared Secret] --> HMAC[HMAC-SHA256]
Payload[Raw JSON Body] --> HMAC
HMAC --> Signature[hex digest]
Provider[Provider] -->|POST body + Signature header| Consumer[Consumer]
Consumer --> Verify{Verify}
Secret --> Verify
Payload --> Verify
Verify -->|Match| Accept[Accept webhook]
Verify -->|Mismatch| Reject[Reject webhook]
The provider and consumer share a secret. The provider computes HMAC of the raw body and includes it as a header. The consumer recomputes HMAC with the same secret and compares.
Provider: Signing a Payload
// Provider-side signing
const crypto = require('crypto');
function signWebhook(payload, secret) {
// Use the raw JSON string, not the parsed object
const rawBody = typeof payload === 'string'
? payload
: JSON.stringify(payload);
const signature = crypto
.createHmac('sha256', secret)
.update(rawBody, 'utf8')
.digest('hex');
return {
signature: `sha256=${signature}`,
rawBody,
};
}
// Usage
const webhookSecret = 'whsec_abc123def456';
const eventPayload = {
event: 'payment.succeeded',
data: { id: 'pi_123', amount: 5000 },
};
const { signature, rawBody } = signWebhook(eventPayload, webhookSecret);
// Headers to send with the POST request
const headers = {
'Content-Type': 'application/json',
'X-Webhook-Signature': signature,
};
Expected output: Signature format is sha256=<hex_digest>. The raw JSON body must be used byte-for-byte. Stringifying the same object twice may produce different spacing.
Consumer: Verifying a Signature
// Consumer-side verification (Node.js)
const express = require('express');
const crypto = require('crypto');
const app = express();
const WEBHOOK_SECRET = 'whsec_abc123def456';
// Capture raw body for signature verification
app.use(express.json({
verify: (req, res, buf) => {
req.rawBody = buf.toString();
},
}));
app.post('/webhooks/payment', (req, res) => {
const signature = req.headers['x-webhook-signature'];
const timestamp = req.headers['x-webhook-timestamp'];
// 1. Verify signature
if (!verifySignature(req.rawBody, signature, WEBHOOK_SECRET)) {
console.error('Invalid signature');
return res.status(401).send('Invalid signature');
}
// 2. Check timestamp (prevent replay)
const age = Date.now() - new Date(timestamp).getTime();
if (age > 300000) { // 5 minutes
console.error('Webhook too old');
return res.status(400).send('Stale webhook');
}
// 3. Process validated webhook
res.status(200).send('OK');
setImmediate(() => processPayment(req.body));
});
function verifySignature(rawBody, signatureHeader, secret) {
if (!signatureHeader || !rawBody) return false;
// Extract the actual hex digest from "sha256=abc123..."
const expectedSig = signatureHeader.startsWith('sha256=')
? signatureHeader.slice(7)
: signatureHeader;
const computedSig = crypto
.createHmac('sha256', secret)
.update(rawBody, 'utf8')
.digest('hex');
// Use timing-safe comparison
return timingSafeEqual(computedSig, expectedSig);
}
function timingSafeEqual(a, b) {
if (a.length !== b.length) {
return false;
}
return crypto.timingSafeEqual(Buffer.from(a), Buffer.from(b));
}
Expected output: Invalid signatures return 401. Valid signatures with old timestamps return 400. Valid and fresh webhooks return 200 and Process normally.
Verification in Python
import hmac
import hashlib
from flask import Flask, request, jsonify
app = Flask(__name__)
WEBHOOK_SECRET = b'whsec_abc123def456'
@app.route('/webhooks/payment', methods=['POST'])
def handle_webhook():
signature = request.headers.get('X-Webhook-Signature')
timestamp = request.headers.get('X-Webhook-Timestamp')
raw_body = request.get_data(as_text=True)
if not verify_signature(raw_body, signature, WEBHOOK_SECRET):
return 'Invalid signature', 401
# Check timestamp
# (parse ISO timestamp, check age)
return 'OK', 200
def verify_signature(raw_body, signature_header, secret):
if not signature_header or not raw_body:
return False
if signature_header.startswith('sha256='):
expected_sig = signature_header[7:]
else:
expected_sig = signature_header
computed_sig = hmac.new(
secret,
raw_body.encode('utf-8'),
hashlib.sha256
).hexdigest()
return hmac.compare_digest(computed_sig, expected_sig)
Expected output: Python uses hmac.compare_digest for timing-safe comparison. Flask's get_data(as_text=True) preserves the raw body exactly as received.
Verification in Go
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"net/http"
"strings"
)
var webhookSecret = []byte("whsec_abc123def456")
func verifySignature(rawBody string, signatureHeader string) bool {
if signatureHeader == "" || rawBody == "" {
return false
}
expectedSig := strings.TrimPrefix(signatureHeader, "sha256=")
mac := hmac.New(sha256.New, webhookSecret)
io.WriteString(mac, rawBody)
computedSig := hex.EncodeToString(mac.Sum(nil))
return hmac.Equal([]byte(computedSig), []byte(expectedSig))
}
func webhookHandler(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
signature := r.Header.Get("X-Webhook-Signature")
if !verifySignature(string(body), signature) {
http.Error(w, "Invalid signature", http.StatusUnauthorized)
return
}
w.WriteHeader(http.StatusOK)
w.Write([]byte("OK"))
}
Expected output: Go uses hmac.Equal for timing-safe comparison. The signature header is extracted and compared against the computed HMAC.
Secret Rotation
// Support multiple secrets for rotation
class WebhookSecretManager {
constructor() {
this.secrets = new Map();
this.currentVersion = 1;
this.rotationInterval = 90 * 24 * 60 * 60 * 1000; // 90 days
}
addSecret(version, secret) {
this.secrets.set(version, secret);
}
getCurrentSecret() {
return this.secrets.get(this.currentVersion);
}
getSecretByVersion(version) {
return this.secrets.get(version);
}
rotateSecret() {
const newVersion = this.currentVersion + 1;
const newSecret = generateSecret();
this.addSecret(newVersion, newSecret);
// Keep old secret for overlap period
console.log(`Rotated to secret v${newVersion}`);
// Schedule old secret removal
setTimeout(() => {
this.secrets.delete(this.currentVersion);
console.log(`Old secret v${this.currentVersion} removed`);
}, this.rotationInterval);
this.currentVersion = newVersion;
}
verifyWithRotation(rawBody, signatureHeader) {
// Try all active secrets
for (const [version, secret] of this.secrets) {
if (verifySignature(rawBody, signatureHeader, secret)) {
return { valid: true, version };
}
}
return { valid: false };
}
}
function generateSecret() {
return crypto.randomBytes(32).toString('hex');
}
Expected output: During rotation, both old and new secrets are valid. Consumers gradually switch to the new secret. After the overlap period, the old secret is removed.
Common Mistakes
1. Comparing Parsed JSON Instead of Raw Body
JSON.stringify produces different output for the same data due to key ordering and whitespace. Always sign and verify the raw HTTP body bytes.
2. Using String Comparison Instead of Timing-Safe
String comparison (===) leaks timing information. Attackers can guess the signature character by character. Use timing-safe comparison (crypto.timingSafeEqual, hmac.compare_digest, hmac.Equal).
3. Not Checking Timestamp
Without timestamp verification, attackers can replay captured webhooks days later. Always check that the webhook timestamp is within 5 minutes of the current time.
4. Storing Secrets in Code or Config Files
Secrets in code or config files are leaked through git history, screenshots, or CI logs. Use environment variables or a secrets manager (Vault, AWS Secrets Manager).
5. No Secret Rotation
A compromised secret gives attackers unlimited fake webhook capabilities. Rotate secrets every 90 days. Support a rotation window where both old and new secrets are valid.
Practice Questions
1. Why must you use the raw HTTP body for HMAC calculation?
Parsed JSON does not preserve the exact byte sequence sent. Key ordering, whitespace, and number formatting can change. HMAC must be computed on the exact bytes received.
2. What is a timing-safe comparison and why is it important?
Timing-safe comparison takes the same time regardless of how many characters match. Regular string comparison short-circuits on the first mismatch, leaking information about the correct signature through response times.
3. How do you handle webhook replay attacks?
Include a timestamp in the webhook headers. The consumer checks that the timestamp is within 5 minutes. Old webhooks are rejected even with valid signatures.
4. How does secret rotation work in webhook systems?
A new secret is generated and shared with consumers. Both old and new secrets are valid during an overlap period (usually the secret's remaining lifetime). After the overlap, the old secret is revoked.
Challenge
Build a complete webhook security system: HMAC-SHA256 signing on the provider, signature verification on the consumer, timestamp validation, secret rotation with overlap period, and support for multiple signature algorithms (SHA256, SHA512).
FAQ
Mini Project: Signature Verification Library
Build a library that: signs webhook payloads with HMAC-SHA256, verifies signatures with timing-safe comparison, supports multiple active secrets for rotation, extracts and validates timestamps, and provides middleware for Express, Flask, and Go HTTP servers.
What's Next
Now that you understand webhook security, learn about Retry Policy to handle delivery failures reliably.
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro