The Silent Killer: Why Webhook Signature Mismatch Errors Happen
A webhook arrives at your endpoint, but the signature verification fails. Your handler rejects it. The provider's retry queue fires again. Logs fill with cryptic "signature verification failed" messages, and you're left wondering: Is the payload corrupted? Did I store the secret correctly? Is my clock skewed?
The webhook signature mismatch error is one of the most frustrating integration bugs because it straddles two worlds—the provider's signing logic and your validation code—with no visibility into either. You can't see the raw bytes the provider signed. You can't replay the exact request. And by the time you realize the issue, you've already lost events. This guide walks you through the root causes and gives you concrete debugging steps to resolve it.
Prerequisites
- A webhook provider account (GitHub, Stripe, Shopify, or similar) with webhook configuration access
- Node.js or Python installed locally; examples use both
- Postman or curl for manual testing
- Basic cryptography knowledge: familiarity with HMAC-SHA256 and hex encoding
- Access to your webhook handler code and environment variables (secret key storage)
Debugging and Fixing Webhook Signature Mismatch Errors
Step 1: Understand Your Provider's Signing Algorithm
Every webhook provider documents their signature scheme. Most use HMAC-SHA256, but the details vary:
-
GitHub: Signs with
HMAC-SHA256(secret, request_body), delivers asX-Hub-Signature-256header in formatsha256=<hex> -
Stripe: Signs with
HMAC-SHA256(secret, timestamp.payload), delivers ast=<timestamp>,v1=<signature>inStripe-Signatureheader -
Shopify: Signs with
HMAC-SHA256(secret, raw_body), delivers as base64-encodedX-Shopify-Hmac-SHA256header
Action item: Find your provider's webhook documentation and copy the exact signing formula. This is non-negotiable.
Step 2: Capture the Exact Request Body
The most common cause of signature mismatch is parsing the body incorrectly. Webhook providers sign the raw bytes, not the parsed JSON. If you parse and re-stringify, whitespace differences will break the signature.
Here's the wrong way (Express):
// ❌ WRONG: body-parser has already parsed the body
app.use(express.json());
app.post('/webhook', (req, res) => {
const signature = req.headers['x-hub-signature-256'];
const payload = req.body; // Already parsed—original bytes lost
// This will fail because we're signing the parsed object, not raw bytes
const hash = crypto
.createHmac('sha256', process.env.WEBHOOK_SECRET)
.update(JSON.stringify(payload))
.digest('hex');
res.status(400).send('Signature mismatch');
});
Here's the right way (Express with raw body capture):
const express = require('express');
const crypto = require('crypto');
const app = express();
// Capture raw body before parsing
app.use(express.raw({ type: 'application/json' }));
app.post('/webhook', (req, res) => {
const signature = req.headers['x-hub-signature-256'];
const rawBody = req.body; // Buffer with original bytes
const expectedSignature = crypto
.createHmac('sha256', process.env.WEBHOOK_SECRET)
.update(rawBody)
.digest('hex');
const incomingSignature = signature.replace('sha256=', '');
if (!crypto.timingSafeEqual(
Buffer.from(incomingSignature),
Buffer.from(expectedSignature)
)) {
return res.status(401).send('Signature verification failed');
}
// Safe to parse now
const payload = JSON.parse(rawBody);
console.log('Verified webhook:', payload);
res.status(200).send('OK');
});
Key detail: Use crypto.timingSafeEqual() to prevent timing attacks. Never use === for signature comparison.
Step 3: Verify Secret Storage and Encoding
Secrets are often stored as hex strings or base64, but signed as raw bytes. A mismatch here is invisible.
import hmac
import hashlib
import os
from base64 import b64decode
# ❌ WRONG: Treating hex string as raw bytes
secret_hex = os.getenv('WEBHOOK_SECRET') # e.g., "abc123def456"
signature = hmac.new(
secret_hex.encode(), # Signing the hex representation, not the bytes!
msg=raw_body,
digestmod=hashlib.sha256
).hexdigest()
# ✅ CORRECT: Decode hex to bytes first
secret_hex = os.getenv('WEBHOOK_SECRET')
secret_bytes = bytes.fromhex(secret_hex)
signature = hmac.new(
secret_bytes, # Sign the actual bytes
msg=raw_body,
digestmod=hashlib.sha256
).hexdigest()
# Also correct: If stored as base64
secret_b64 = os.getenv('WEBHOOK_SECRET')
secret_bytes = b64decode(secret_b64)
signature = hmac.new(
secret_bytes,
msg=raw_body,
digestmod=hashlib.sha256
).hexdigest()
Check: Log the first 8 characters of your secret (never the full secret) and compare it to what the provider shows in their UI. If they don't match, you've copied the wrong secret.
Step 4: Debug with Logging and Replay
Add detailed logging to your signature verification:
app.post('/webhook', (req, res) => {
const signature = req.headers['x-hub-signature-256'];
const rawBody = req.body;
console.log('Received signature:', signature);
console.log('Raw body length:', rawBody.length);
console.log('Raw body (first 100 chars):', rawBody.toString().slice(0, 100));
const expectedSignature = crypto
.createHmac('sha256', process.env.WEBHOOK_SECRET)
.update(rawBody)
.digest('hex');
console.log('Expected signature:', expectedSignature);
console.log('Match:', signature === `sha256=${expectedSignature}`);
// ... rest of verification
});
Once you identify the mismatch, use Test Webhooks Locally Without ngrok to capture the exact webhook and replay it locally with modified code. With Anonymily Pro, you can replay with signature verification helpers to isolate the exact byte difference.
Common Errors and Fixes
Error 1: "Signature verification failed" on Every Webhook
Root cause: The raw body is being consumed by middleware before you capture it.
Exact error message:
Signature verification failed: incoming=sha256=abc123... expected=sha256=def456...
Fix: Ensure middleware captures the raw body before parsing:
// Middleware order matters!
app.use(express.raw({ type: 'application/json' })); // First
app.use(express.json()); // Second (for other routes)
app.post('/webhook', (req, res) => {
// req.body is still a Buffer here
});
If you're using a framework like Fastify, use bodyLimit and rawBody options:
const fastify = require('fastify');
const app = fastify({
bodyLimit: 1048576,
});
app.post('/webhook', async (request, reply) => {
const signature = request.headers['x-hub-signature-256'];
const rawBody = request.rawBody; // Fastify provides this
// Verify signature against rawBody
});
Error 2: "Signature Mismatch" in Production, Not Local
Root cause: Clock skew or timezone differences. Some providers (Stripe, Razorpay) include a timestamp in the signed payload and reject old signatures.
Exact error message:
Signature verification failed: timestamp too old
Fix: Sync your server's clock with NTP:
# Linux/macOS
ntpdate -s time.nist.gov
# Or use timedatectl (systemd)
timedatectl set-ntp true
In your code, allow a small time window (e.g., 5 minutes):
const verifyStripeSignature = (rawBody, signature, secret) => {
const [timestamp, v1] = signature.split(',').reduce((acc, part) => {
const [key, val] = part.split('=');
acc[key === 't' ? 0 : 1] = val;
return acc;
}, []);
const now = Math.floor(Date.now() / 1000);
const age = now - parseInt(timestamp);
if (age > 300) { // 5 minutes
throw new Error('Timestamp too old');
}
const expectedSignature = crypto
.createHmac('sha256', secret)
.update(`${timestamp}.${rawBody}`)
.digest('hex');
return crypto.timingSafeEqual(
Buffer.from(v1),
Buffer.from(expectedSignature)
);
};
Frequently Asked Questions
Q: How do I test webhook signature verification locally without the real provider?
A: Use your provider's test/sandbox mode to send real signed webhooks to a local endpoint. If that's not available, manually compute the signature using the provider's algorithm and send it via curl or Postman. For GitHub, Shopify, and other providers, Anonymily Pro includes synthetic provider-signed test events you can replay locally with full signature validation.
Q: Why does my signature match locally but fail in production?
A: The most common cause is middleware order or environment variable differences. Verify that WEBHOOK_SECRET is set identically in both environments (check for trailing whitespace or encoding differences). Also check that your raw body capture middleware runs before any parsing middleware. Use console.log to compare the first 8 hex characters of the secret in both environments.
Q: Can I use a different hash algorithm than HMAC-SHA256?
A: No. Your provider chose HMAC-SHA256 for a reason—it's the industry standard. If your provider supports a different algorithm, they'll document it explicitly. Never assume or substitute algorithms; always use exactly what the provider specifies in their webhook documentation.
Signature mismatches are almost always caused by one of three things: raw body capture, secret encoding, or clock skew. Start by logging the exact bytes you're signing and comparing them to the provider's documentation. If you're stuck, try Mastering Webhook Signature Verification for a deeper dive into verification patterns.
For local development and debugging, npx @anonymilyhq/cli listen 3000 captures real webhooks and replays them to your handler—letting you iterate on signature verification without waiting for provider retries. Visit anonymily.com to get started.
Top comments (0)