Webhook Signature Verifier Guide: Secure Event Validation
Technical Mastery Overview
Why Webhook Signature Verification Is Non-Negotiable
Webhook endpoints are designed to be called by external services — they're publicly accessible URLs that receive POST requests. Without verification, any actor with your endpoint URL can:
- Trigger fake order confirmations
- Send false payment success events
- Inject fraudulent user registrations
- Cause unauthorized workflow executions (CI/CD triggers, Slack commands)
- Flood your system with noise to mask real events
Signature verification confirms two things: (1) the event came from the legitimate provider who knows the shared secret, and (2) the payload wasn't modified in transit.
How HMAC-SHA256 Signing Works
Most major webhook providers use HMAC-SHA256 (Hash-based Message Authentication Code with SHA-256):
- Provider signs:
HMAC-SHA256(secret, canonical_payload)→ signature - Provider sends: signature in a header alongside the raw payload
- You verify: recompute
HMAC-SHA256(secret, raw_body)and compare
const crypto = require('crypto');
function verifyWebhook(rawBody, secretKey, receivedSignature) {
const computedSignature = crypto
.createHmac('sha256', secretKey)
.update(rawBody, 'utf8')
.digest('hex');
// Constant-time comparison to prevent timing attacks
return crypto.timingSafeEqual(
Buffer.from(computedSignature),
Buffer.from(receivedSignature)
);
}
Never use === for signature comparison — it short-circuits on the first mismatch, allowing timing attacks to guess the signature byte by byte. Always use crypto.timingSafeEqual() or its equivalent.
Provider-Specific Signature Formats
Different providers structure their signatures differently:
Stripe
Stripe-Signature: t=1709296000,v1=2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824
Stripe's canonical payload: {timestamp}.{raw_body}
const signedPayload = `${timestamp}.${rawBody}`;
const signature = hmacSHA256(webhookSecret, signedPayload);
The t= timestamp is included in the signed payload — this enables replay protection (reject if timestamp is more than 5 minutes old).
GitHub
X-Hub-Signature-256: sha256=2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824
GitHub's canonical payload: just the raw request body, no timestamp.
const signature = 'sha256=' + hmacSHA256(webhookSecret, rawBody);
Slack
X-Slack-Signature: v0=2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824
X-Slack-Request-Timestamp: 1709296000
Slack's canonical payload: v0:{timestamp}:{raw_body}
const baseString = `v0:${timestamp}:${rawBody}`;
const signature = 'v0=' + hmacSHA256(signingSecret, baseString);
Shopify
X-Shopify-Hmac-SHA256: base64-encoded-signature
Shopify uses Base64-encoded (not hex) HMAC output, and signs the raw body without a timestamp prefix.
Use our Base64 Decoder to convert Shopify's Base64 signature to hex for comparison, or our Hash Generator to verify HMAC values manually.
The Raw Body Problem — The Most Common Bug
The single most common webhook verification failure: the body was already parsed (JSON.parse'd) before verification, so the bytes used for HMAC computation don't match the original raw bytes.
// Express.js — WRONG order
app.use(express.json()); // Parses body BEFORE route
app.post('/webhook', (req, res) => {
// req.body is now a JS object, not raw bytes — verification FAILS
verify(req.body, req.headers['x-signature']);
});
// Correct — read raw body for webhook routes
app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {
// req.body is Buffer — raw bytes — verification SUCCEEDS
verify(req.body, req.headers['x-signature']);
});
Whitespace normalization, JSON key reordering, and character encoding changes all produce different bytes from the same JSON object. The provider signs the exact bytes they sent — you must verify against those same bytes.
Replay Attacks and Timestamp Windows
A valid signature doesn't guarantee the event is fresh. An attacker can capture a legitimate webhook and replay it hours later. The solution: include a timestamp in the signature and reject stale events.
function isWithinWindow(timestamp, toleranceSeconds = 300) {
const now = Math.floor(Date.now() / 1000);
return Math.abs(now - timestamp) <= toleranceSeconds;
}
// Reject if older than 5 minutes
if (!isWithinWindow(webhookTimestamp)) {
return res.status(400).json({ error: 'Webhook timestamp too old' });
}
Five minutes is the standard tolerance window (Stripe uses this). Shorter windows are more secure but require tight clock synchronization between sender and receiver. Use our Timestamp Converter to verify webhook timestamps when debugging stale-event rejections.
Secret Rotation Without Dropping Events
When rotating a webhook secret, there's a window where the provider is using the new secret but you may still receive events signed with the old one. Handle this gracefully:
function verifyWithFallback(rawBody, signature, secrets) {
for (const secret of secrets) {
const computed = hmacSHA256(secret, rawBody);
if (timingSafeEqual(computed, signature)) {
return true;
}
}
return false;
}
// During rotation: try new secret first, fall back to old
const isValid = verifyWithFallback(rawBody, signature, [newSecret, oldSecret]);
The rotation window should be short — a few minutes to an hour — then remove the old secret from the fallback list.
Debugging Failed Verification
When signature verification fails, check in this order:
- Raw body integrity — are you using the raw request bytes, not a parsed object?
- Encoding — is the signature hex or Base64? Are you comparing hex-to-hex?
- Canonical payload format — does it include a timestamp prefix? What's the exact format?
- Secret — is it the webhook secret, not the API key? Are there leading/trailing spaces?
- Timestamp — is the event within the tolerance window?
- Header name — is the signature in
X-Hub-Signature-256orX-Hub-Signature? (SHA-256 vs SHA-1)
Our verifier lets you test each component — paste the raw payload, secret, and received signature to see exactly what your computed signature is and where the mismatch occurs.
Building the Test Workflow
- Construct the test request with our cURL Generator
- Compute the expected signature locally with our verifier
- Send the request and compare headers
- Validate the JSON payload structure with our JSON Formatter and JSON Schema Validator
- Sanitize logs with our PII Redactor before sharing in incident reports
For generating strong webhook secrets, use our Password Generator with 32+ characters — the HMAC security is bounded by the secret's entropy.
Experience it now.
Use the professional-grade Webhook Signature Verifier with zero latency and 100% privacy in your browser.