How to Secure Webhooks: HMAC Verification and Best Practices
Every major webhook provider -- Stripe, GitHub, Shopify, Twilio, Discord -- sends a signature with each webhook delivery. Most developers skip verifying it during development and never add it in production. That is a serious security mistake.
Without signature verification, anyone who discovers your webhook endpoint URL can send fake events to your server: forged payment confirmations, fabricated order updates, spoofed user signups. This guide explains how webhook signatures work and how to implement proper verification in your handler.
Why Webhook Signature Verification Matters
Your webhook endpoint is a public URL. If it accepts unauthenticated POST requests, an attacker can:
- Send a fake
payment_intent.succeededevent to trigger order fulfillment without paying - Inject malicious payloads that exploit your parsing logic
- Flood your endpoint with events to exhaust rate limits or cause downstream issues
Signature verification solves this by proving that a webhook was sent by the service you expected, not a third party.
How HMAC Signatures Work
Most webhook providers use HMAC-SHA256 signatures. Here is the process:
- The provider takes the raw request body
- Signs it with a secret key using HMAC-SHA256
- Sends the signature in a request header (e.g.,
Stripe-Signature,X-Hub-Signature-256,X-Shopify-Hmac-SHA256) - Your server recomputes the signature using the same secret and compares
If the signatures match, the request is authentic. If they do not match, reject it with a 400 or 401.
Provider Server Your Server
| |
| POST /webhook |
| Body: {"event": "payment..."} |
| Stripe-Signature: t=...,v1=... |
|--------------------------------->|
| |
| | 1. Extract raw body
| | 2. Extract signature from header
| | 3. Compute HMAC-SHA256(secret, body)
| | 4. Compare computed vs received
| | 5. Accept or reject
The key word is raw body. If you parse the JSON first and then re-serialize it, the byte-level content may change and the signature comparison will fail. Always sign and verify against the original raw bytes.
Stripe: Signature Verification
Stripe sends a Stripe-Signature header with a timestamp and multiple signatures. The timestamp prevents replay attacks (where an attacker captures a legitimate webhook and re-sends it later).
Stripe-Signature: t=1712345678,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a05bd445be62536974f39a
Node.js (Express)
const express = require('express');
const crypto = require('crypto');
const app = express();
// IMPORTANT: use raw body, not parsed JSON
app.post('/webhook/stripe', express.raw({ type: 'application/json' }), (req, res) => {
const sig = req.headers['stripe-signature'];
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET;
// Parse the Stripe-Signature header
const parts = sig.split(',').reduce((acc, part) => {
const [key, value] = part.split('=');
acc[key] = value;
return acc;
}, {});
const timestamp = parts.t;
const receivedSig = parts.v1;
// Compute the expected signature
const payload = `${timestamp}.${req.body}`;
const expectedSig = crypto
.createHmac('sha256', webhookSecret)
.update(payload, 'utf8')
.digest('hex');
// Constant-time comparison to prevent timing attacks
const sigBuffer = Buffer.from(receivedSig, 'hex');
const expectedBuffer = Buffer.from(expectedSig, 'hex');
if (sigBuffer.length !== expectedBuffer.length || !crypto.timingSafeEqual(sigBuffer, expectedBuffer)) {
return res.status(400).send('Invalid signature');
}
// Check timestamp to prevent replay attacks (5 minute tolerance)
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - parseInt(timestamp)) > 300) {
return res.status(400).send('Timestamp too old');
}
const event = JSON.parse(req.body);
// Handle event...
res.json({ received: true });
});
Stripe also provides an official SDK that handles this for you:
const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);
app.post('/webhook/stripe', express.raw({ type: 'application/json' }), (req, res) => {
let event;
try {
event = stripe.webhooks.constructEvent(
req.body,
req.headers['stripe-signature'],
process.env.STRIPE_WEBHOOK_SECRET
);
} catch (err) {
return res.status(400).send(`Webhook Error: ${err.message}`);
}
// Handle event...
res.json({ received: true });
});
Python (Flask)
import hmac
import hashlib
import json
from flask import Flask, request, abort
app = Flask(__name__)
@app.route('/webhook/stripe', methods=['POST'])
def stripe_webhook():
payload = request.get_data() # raw bytes
sig_header = request.headers.get('Stripe-Signature', '')
webhook_secret = os.environ['STRIPE_WEBHOOK_SECRET'].encode('utf-8')
# Parse the Stripe-Signature header
parts = dict(part.split('=', 1) for part in sig_header.split(','))
timestamp = parts.get('t', '')
received_sig = parts.get('v1', '')
# Compute expected signature
signed_payload = f"{timestamp}.".encode('utf-8') + payload
expected_sig = hmac.new(webhook_secret, signed_payload, hashlib.sha256).hexdigest()
# Constant-time comparison
if not hmac.compare_digest(expected_sig, received_sig):
abort(400, 'Invalid signature')
event = json.loads(payload)
# Handle event...
return {'received': True}
GitHub: Webhook Signature Verification
GitHub uses the X-Hub-Signature-256 header with a simpler format: sha256=<hex_signature>.
app.post('/webhook/github', express.raw({ type: 'application/json' }), (req, res) => {
const sig = req.headers['x-hub-signature-256'];
if (!sig) {
return res.status(401).send('No signature');
}
const secret = process.env.GITHUB_WEBHOOK_SECRET;
const expected = 'sha256=' + crypto
.createHmac('sha256', secret)
.update(req.body)
.digest('hex');
const sigBuffer = Buffer.from(sig);
const expectedBuffer = Buffer.from(expected);
if (sigBuffer.length !== expectedBuffer.length || !crypto.timingSafeEqual(sigBuffer, expectedBuffer)) {
return res.status(401).send('Invalid signature');
}
const payload = JSON.parse(req.body);
const event = req.headers['x-github-event'];
// Handle event based on type...
res.json({ received: true });
});
Go: Generic HMAC Verification
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"io"
"net/http"
"os"
)
func verifyHMAC(body []byte, signature, secret string) bool {
mac := hmac.New(sha256.New, []byte(secret))
mac.Write(body)
expected := hex.EncodeToString(mac.Sum(nil))
// Use hmac.Equal for constant-time comparison
return hmac.Equal([]byte(expected), []byte(signature))
}
func webhookHandler(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "Bad request", http.StatusBadRequest)
return
}
sig := r.Header.Get("X-Hub-Signature-256")
// Strip "sha256=" prefix if present
if len(sig) > 7 && sig[:7] == "sha256=" {
sig = sig[7:]
}
if !verifyHMAC(body, sig, os.Getenv("WEBHOOK_SECRET")) {
http.Error(w, "Invalid signature", http.StatusUnauthorized)
return
}
// Process webhook...
w.WriteHeader(http.StatusOK)
}
Common Mistakes
1. Parsing JSON Before Verification
// WRONG: body-parser changes the raw bytes
app.use(express.json());
app.post('/webhook', (req, res) => {
// req.body is already parsed — signature check will fail
verifySignature(JSON.stringify(req.body), sig, secret); // don't do this
});
// CORRECT: keep raw bytes for webhook routes
app.post('/webhook', express.raw({ type: '*/*' }), (req, res) => {
verifySignature(req.body, sig, secret); // req.body is a Buffer
});
2. String Comparison Instead of Constant-Time
// WRONG: timing attack possible
if (computedSig === receivedSig) { ... }
// CORRECT: constant-time comparison
if (crypto.timingSafeEqual(Buffer.from(computedSig), Buffer.from(receivedSig))) { ... }
Timing attacks are theoretical but real — use constant-time comparison whenever comparing secrets.
3. No Replay Attack Protection
A valid signature only proves the request was sent by the right party. It does not prevent replay: an attacker could capture a legitimate webhook delivery and re-send it hours later. To prevent this, check the timestamp in the signature and reject requests older than a few minutes (typically 5 minutes).
4. Storing the Secret in Source Code
Use environment variables or a secrets manager. Never commit webhook secrets to your repository.
Testing Signature Verification with HookCap
Before going live, use HookCap to inspect and replay webhook deliveries and verify your signature logic handles real payloads correctly.
- Create a HookCap endpoint — you get a persistent HTTPS URL
- Configure that URL as your webhook destination in Stripe/GitHub/Shopify
- Trigger real events from the provider dashboard
- Inspect the raw headers, including the signature header
- Use HookCap's replay feature to resend a captured payload to your local server
This is especially useful for debugging signature failures — you can see the exact header value the provider sent and compare it against what your code computed.
HookCap's Auto-Forward feature (Pro) also lets you forward captured webhooks directly to localhost, so you can test your local handler with real production payloads without any tunnel setup.
Shopify and Twilio Verification
The same pattern applies to other providers:
Shopify — X-Shopify-Hmac-SHA256 contains a base64-encoded (not hex) HMAC-SHA256 signature:
const crypto = require('crypto');
function verifyShopifyWebhook(body, signature, secret) {
const expected = crypto
.createHmac('sha256', secret)
.update(body)
.digest('base64');
return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature));
}
Twilio — uses its own X-Twilio-Signature header with a slightly different algorithm. Twilio signs a concatenation of the URL and sorted POST parameters, not just the body. Use the official Twilio Node.js SDK's validateRequest helper.
Summary
Webhook signature verification is not optional for production systems. The steps are always the same:
- Read the raw request body — do not parse it first
- Extract the signature from the appropriate header
- Recompute the HMAC using your webhook secret
- Compare using constant-time equality
- Check the timestamp if the provider includes one
Each provider has its own header name and sometimes its own signing algorithm details, but the underlying concept is identical. Pick up the SDK when the provider has one — it handles the edge cases for you.
Top comments (0)