Webhook security: HMAC signatures, idempotency, and replay protection
Your webhook handler accepts a POST request, parses the JSON, and runs business logic. That works until someone sends a forged payload to your endpoint. Or until the provider retries a delivery and your handler charges a customer twice. Or until an attacker records a legitimate request and replays it six hours later.
Three protections fix this: HMAC signature verification, idempotency keys, and timestamp-based replay rejection. This tutorial covers each one with working Node.js code.
How webhook signing works
Every major webhook provider (Stripe, GitHub, Shopify, Twilio) signs outgoing payloads with HMAC-SHA256. The provider combines the raw request body with a shared secret, computes a hash, and sends that hash in a header. Your server recomputes the hash with the same secret and compares. If the hashes match, the payload is authentic.
The signing formula looks like this:
HMAC-SHA256(secret, timestamp + "." + raw_body) = signature
Stripe sends the signature in Stripe-Signature. GitHub sends it in X-Hub-Signature-256.
The algorithm is the same; only the header name and format differ.
Verify a Stripe webhook signature in Node.js
Stripe's Stripe-Signature header contains a timestamp (t=) and one or more versioned
signatures (v1=). Here is how to verify it without the Stripe SDK:
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);
const signedPayload = timestamp + '.' + payload;
const expected = crypto
.createHmac('sha256', secret)
.update(signedPayload)
.digest('hex');
if (expected !== signature) {
throw new Error('Invalid signature');
}
// Replay protection: reject if older than 5 minutes
const age = Math.floor(Date.now() / 1000) - parseInt(timestamp, 10);
if (age > 300) {
throw new Error('Timestamp too old');
}
return JSON.parse(payload);
}
Key details: Stripe concatenates the timestamp and the raw body with a period separator. The v1
signature uses HMAC-SHA256. The timestamp check at the end provides replay protection (covered in detail below).
Verify a GitHub webhook signature in Node.js
GitHub's format is simpler. The X-Hub-Signature-256 header contains sha256= followed
by the hex HMAC of the raw body:
const crypto = require('crypto');
function verifyGitHubSignature(payload, signatureHeader, secret) {
const expected = 'sha256=' + crypto
.createHmac('sha256', secret)
.update(payload)
.digest('hex');
const trusted = Buffer.from(expected, 'utf8');
const untrusted = Buffer.from(signatureHeader, 'utf8');
if (trusted.length !== untrusted.length) {
throw new Error('Invalid signature');
}
if (!crypto.timingSafeEqual(trusted, untrusted)) {
throw new Error('Invalid signature');
}
return JSON.parse(payload);
}
Notice crypto.timingSafeEqual. A standard === comparison leaks timing information.
An attacker can measure response times to guess the correct signature byte by byte. Constant-time comparison
eliminates that vector.
Compute HMAC signatures with botoi's hash API
If you need to compute or verify HMAC-SHA256 signatures from a script, CI pipeline, or serverless function
without importing crypto, use the /v1/hash/hmac endpoint:
curl -X POST https://api.botoi.com/v1/hash/hmac \
-H "Content-Type: application/json" \
-d '{
"text": "1712345678.{\"event\":\"payment_intent.succeeded\",\"amount\":4999}",
"key": "whsec_your_stripe_secret",
"algorithm": "sha256"
}' Response:
{
"success": true,
"data": {
"hmac": "a3f2b8c9d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0",
"algorithm": "sha256"
}
}
This returns the hex-encoded HMAC. Compare it against the signature header from the webhook provider.
The endpoint supports both sha256 and sha512 algorithms.
Build an Express middleware for HMAC verification
Wrap the signature check into reusable middleware so every webhook route gets protection without duplicating code:
const crypto = require('crypto');
function webhookAuth(secret) {
return (req, res, next) => {
const signature = req.headers['x-webhook-signature'];
const timestamp = req.headers['x-webhook-timestamp'];
if (!signature || !timestamp) {
return res.status(401).json({ error: 'Missing signature headers' });
}
// Step 1: Replay protection
const age = Math.floor(Date.now() / 1000) - parseInt(timestamp, 10);
if (age > 300) {
return res.status(401).json({ error: 'Request too old' });
}
// Step 2: HMAC verification
const payload = JSON.stringify(req.body);
const expected = crypto
.createHmac('sha256', secret)
.update(timestamp + '.' + payload)
.digest('hex');
const trusted = Buffer.from(expected, 'utf8');
const untrusted = Buffer.from(signature, 'utf8');
if (trusted.length !== untrusted.length ||
!crypto.timingSafeEqual(trusted, untrusted)) {
return res.status(401).json({ error: 'Invalid signature' });
}
next();
};
}
// Mount on your webhook route
app.post('/webhooks/stripe', webhookAuth(process.env.STRIPE_WEBHOOK_SECRET), handler); This middleware rejects requests with missing headers, stale timestamps, or invalid signatures before your handler runs. The 300-second window matches Stripe's default tolerance.
Idempotency: skip duplicate deliveries
Webhook providers retry failed deliveries. If your server returned a 500 after processing the event but before sending the 200 response, the provider sends the same event again. Without idempotency, your handler runs the side effects twice: double charges, duplicate emails, repeated inventory updates.
The fix: store each processed event ID and skip events you have seen before.
In-memory idempotency (development)
const processedEvents = new Map(); // Use Redis in production
async function handleWebhook(req, res) {
const eventId = req.body.id;
// Check if already processed
if (processedEvents.has(eventId)) {
return res.status(200).json({ status: 'duplicate', eventId });
}
// Mark as processing before side effects
processedEvents.set(eventId, Date.now());
try {
await processEvent(req.body);
return res.status(200).json({ status: 'processed', eventId });
} catch (err) {
// Remove on failure so retries work
processedEvents.delete(eventId);
return res.status(500).json({ error: 'Processing failed' });
}
}
The Map works for local development. In production, use a shared store so all server instances
see the same set of processed events.
Redis-backed idempotency (production)
const Redis = require('ioredis');
const redis = new Redis(process.env.REDIS_URL);
async function handleWebhook(req, res) {
const eventId = req.body.id;
const lockKey = `webhook:processed:${eventId}`;
// SET NX with 72-hour TTL
const acquired = await redis.set(lockKey, Date.now(), 'NX', 'EX', 259200);
if (!acquired) {
return res.status(200).json({ status: 'duplicate', eventId });
}
try {
await processEvent(req.body);
return res.status(200).json({ status: 'processed', eventId });
} catch (err) {
await redis.del(lockKey);
return res.status(500).json({ error: 'Processing failed' });
}
} SET NX is atomic: only one process wins the lock. The 72-hour TTL (259200 seconds)
auto-prunes old entries. If processing fails, the handler deletes the key so the next retry can succeed.
Replay protection: reject stale requests
HMAC verification confirms the payload came from the provider. But a valid signed request captured by a network attacker stays valid forever unless you add a time check. Replay protection rejects payloads with timestamps older than a threshold.
function rejectStaleRequests(req, res, next) {
const timestamp = parseInt(req.headers['x-webhook-timestamp'], 10);
if (isNaN(timestamp)) {
return res.status(401).json({ error: 'Missing timestamp' });
}
const now = Math.floor(Date.now() / 1000);
const age = now - timestamp;
// Reject anything older than 5 minutes or from the future
if (age > 300 || age < -30) {
return res.status(401).json({ error: 'Timestamp out of range' });
}
next();
} The 30-second future tolerance handles minor clock drift between your server and the provider. Anything older than 5 minutes or more than 30 seconds in the future gets rejected.
Combine all three into one middleware
Here is the complete middleware that runs all three checks in sequence: timestamp, HMAC, then idempotency.
const crypto = require('crypto');
const Redis = require('ioredis');
const redis = new Redis(process.env.REDIS_URL);
function secureWebhook(secret) {
return async (req, res, next) => {
const signature = req.headers['x-webhook-signature'];
const timestamp = req.headers['x-webhook-timestamp'];
const eventId = req.body?.id;
// 1. Reject missing headers
if (!signature || !timestamp || !eventId) {
return res.status(401).json({ error: 'Missing required headers or event ID' });
}
// 2. Replay protection: reject stale requests
const age = Math.floor(Date.now() / 1000) - parseInt(timestamp, 10);
if (age > 300 || age < -30) {
return res.status(401).json({ error: 'Timestamp out of range' });
}
// 3. HMAC verification
const payload = JSON.stringify(req.body);
const expected = crypto
.createHmac('sha256', secret)
.update(timestamp + '.' + payload)
.digest('hex');
const trusted = Buffer.from(expected, 'utf8');
const untrusted = Buffer.from(signature, 'utf8');
if (trusted.length !== untrusted.length ||
!crypto.timingSafeEqual(trusted, untrusted)) {
return res.status(401).json({ error: 'Invalid signature' });
}
// 4. Idempotency: skip duplicates
const lockKey = `webhook:processed:${eventId}`;
const acquired = await redis.set(lockKey, Date.now(), 'NX', 'EX', 259200);
if (!acquired) {
return res.status(200).json({ status: 'duplicate', eventId });
}
// Attach cleanup function for error recovery
res.locals.idempotencyKey = lockKey;
next();
};
} The order matters. Timestamp is the cheapest check (no I/O), so it runs first. HMAC verification is CPU-bound but still fast. Idempotency requires a Redis call, so it runs last. This sequence rejects the most requests at the lowest cost.
Test your webhook handler with botoi's webhook inbox
Use botoi's /v1/webhook/inbox to create a disposable URL, compute an HMAC signature with
/v1/hash/hmac, and send a signed test payload. No tunnels, no local server.
# 1. Create a webhook inbox
INBOX=$(curl -s -X POST https://api.botoi.com/v1/webhook/inbox/create)
INBOX_ID=$(echo $INBOX | jq -r '.data.inbox_id')
INBOX_URL=$(echo $INBOX | jq -r '.data.url')
echo "Inbox URL: $INBOX_URL"
# 2. Compute an HMAC signature for your test payload
PAYLOAD='{"id":"evt_001","event":"order.created","amount":2999}'
TIMESTAMP=$(date +%s)
SIGNED_PAYLOAD="$TIMESTAMP.$PAYLOAD"
HMAC=$(curl -s -X POST https://api.botoi.com/v1/hash/hmac \
-H "Content-Type: application/json" \
-d "{
\"text\": \"$SIGNED_PAYLOAD\",
\"key\": \"test_secret_key\",
\"algorithm\": \"sha256\"
}" | jq -r '.data.hmac')
echo "HMAC: $HMAC"
# 3. Send the signed payload to your inbox
curl -s -X POST "$INBOX_URL" \
-H "Content-Type: application/json" \
-H "X-Webhook-Signature: $HMAC" \
-H "X-Webhook-Timestamp: $TIMESTAMP" \
-d "$PAYLOAD"
# 4. Retrieve and inspect the payload
curl -s -X POST "https://api.botoi.com/v1/webhook/inbox/$INBOX_ID/list" | jq '.data.payloads' The list endpoint returns all received payloads:
[
{
"id": "p_abc123",
"received_at": "2026-04-05T14:30:00Z",
"body": {
"id": "evt_001",
"event": "order.created",
"amount": 2999
}
}
] This workflow lets you test the full signing flow from the command line. Swap the inbox URL for your production endpoint to run integration tests in CI.
How Stripe, GitHub, and Shopify sign webhooks
| Provider | Header | Algorithm | Signed payload format | Timestamp included |
|---|---|---|---|---|
| Stripe | Stripe-Signature | HMAC-SHA256 | t=UNIX.v1=HEX | Yes |
| GitHub | X-Hub-Signature-256 | HMAC-SHA256 | sha256=HEX | No |
| Shopify | X-Shopify-Hmac-Sha256 | HMAC-SHA256 | Base64-encoded | No |
| Twilio | X-Twilio-Signature | HMAC-SHA1 | URL + sorted params | No |
Stripe is the only one that bundles a timestamp with the signature, giving you replay protection out of the box. For GitHub and Shopify, add your own timestamp header or check the event creation time from the payload body.
Checklist before going to production
- Store the webhook secret in an environment variable, not in source code.
- Parse the raw request body, not the JSON-parsed version. HMAC signs the exact bytes on the wire.
- Use
crypto.timingSafeEqualfor signature comparison. Never use===. - Return 200 before running slow processing. Queue the work and acknowledge the delivery.
- Log rejected requests (invalid signature, stale timestamp, duplicate) for debugging and alerting.
- Set a TTL on your idempotency store. 72 hours covers most provider retry windows.
- Test with a disposable inbox and computed HMAC signatures before connecting a live provider.
Frequently asked questions
- Why use HMAC-SHA256 instead of a shared secret in a query parameter?
- A query parameter travels in plaintext through proxies and access logs. HMAC-SHA256 signs the entire request body with the secret, so an attacker who intercepts the URL still cannot forge a valid signature.
- How long should I keep processed event IDs for idempotency?
- Keep them for at least 24 to 72 hours. Most webhook providers retry within that window. After 72 hours, you can safely prune old IDs from your store.
- What timestamp tolerance should I use for replay protection?
- Five minutes (300 seconds) is the standard. Stripe uses 300 seconds. Shorter windows risk rejecting legitimate deliveries delayed by network congestion.
- Can I use botoi hash/hmac endpoint to verify incoming webhooks?
- Yes. POST the payload body and your secret to /v1/hash/hmac with algorithm sha256. Compare the returned HMAC against the signature header from the webhook provider.
- Do I need all three protections or can I pick one?
- HMAC verification is the minimum. Add idempotency if your webhook handler triggers side effects like charges or emails. Add replay protection if your system handles financial transactions or security-sensitive events.
More tutorial posts
Start building with botoi
150+ API endpoints for lookup, text processing, image generation, and developer utilities. Free tier, no credit card.