Webhook Signature Verification Example
A webhook is the service side calling your callback URL proactively. Because the callback URL is public, anyone who learns the address can forge requests and inject fake data. Signature verification is the "ID card" for requests: the shared secret is used to compute an HMAC over the request body, and only requests carrying a matching signature are allowed through. Below is a complete walkthrough in Node.js.
First, think about what happens without signature verification. An attacker who scans your callback URL can forge a "user paid" webhook; if your business logic ships the goods as soon as a payment success arrives, you are giving products away for free. Even without shipping, forged events can pollute analytics, trigger duplicate emails, and dirty the database. A more subtle attack is replay: the attacker intercepts a real payment callback and sends it again unchanged; without deduplication the user gets double-charged or double-shipped. So signature verification must solve at least two problems: is the request from a provider you trust, and is the request fresh rather than replayed.
A Runnable Verification Function
const crypto = require('crypto');
function verifySignature(payload, signature, secret) {
const expected = crypto
.createHmac('sha256', secret)
.update(payload, 'utf8')
.digest('hex');
return crypto.timingSafeEqual(
Buffer.from(expected),
Buffer.from(signature)
);
}
Three Key Details
First, verify against the raw request body. Once the body is parsed, reformatted, or re-encoded, the hash changes and every legitimate request gets rejected. With Express, be careful: the default express.json() middleware parses the body into a JSON object, and re-serializing with JSON.stringify can reorder fields or alter spacing, so the signature never matches. In production, preserve the raw body stream — use express.raw() on the webhook route only, or stash the raw body in req.rawBody. Second, timingSafeEqual compares two strings in constant time, avoiding the timing side-channel that a plain === comparison leaks — if the comparison can end early, an attacker can guess the signature byte by byte from response times. Third, the signature usually arrives in a header (such as GitHub's X-Hub-Signature-256 or Stripe's Stripe-Signature); inspect its format before comparing.
Signature Rules at Popular Platforms
| Platform | Header | Algorithm | Prefix |
|---|---|---|---|
| GitHub | X-Hub-Signature-256 |
HMAC-SHA256 | sha256= |
| Stripe | Stripe-Signature |
HMAC-SHA256 (with timestamp) | t=...,v1=... |
| WeChat Pay | Wechatpay-Signature |
RSA/SM2 | none |
The definition of "signed content" differs across platforms. GitHub hashes the raw body; Stripe signs a concatenation of timestamp and body, and also checks the timestamp to prevent replay. Always read the platform documentation before integrating to pin down the exact signed content and encoding.
Timestamp-Based Replay Protection (Stripe Style)
Some platforms put a timestamp in the signature header precisely so you can add replay protection. Stripe's header looks like t=1700000000,v1=.... Check the timestamp t first — if it differs from the server time by more than 5 minutes, reject the request; this blocks "capture an old request and replay it" attacks. Once the timestamp passes, recompute the HMAC over v1=timestamp.raw-body and compare.
function verifyStripeSignature(payload, header, secret) {
const parts = Object.fromEntries(
header.split(',').map(p => p.split('='))
);
const timestamp = parseInt(parts.t, 10);
// Timestamp expiry check (5 minutes)
if (Math.abs(Date.now() / 1000 - timestamp) > 300) return false;
const signed = `${timestamp}.${payload}`;
const expected = crypto
.createHmac('sha256', secret)
.update(signed, 'utf8')
.digest('hex');
return crypto.timingSafeEqual(
Buffer.from(expected), Buffer.from(parts.v1)
);
}
Key Rotation and Versioning
Signature verification is only as strong as the shared secret itself, so the key must be rotated regularly. The scariest part of rotation is "the moment the key changes, all legitimate old requests get rejected". The safe approach is dual-key rollout: temporarily accept both the old and new secrets server-side (the old key is only used for verification, never for signing), then remove the old key once the platform has fully switched. Many platforms carry a version number in the signature header (such as Stripe's v1) precisely to enable this smooth rotation. Also plan for the day a key leaks: you must be able to revoke and re-issue immediately, so store keys centrally in a secret manager with audit logging enabled.
The Full Callback Flow
const express = require('express');
const app = express();
app.post('/webhook', (req, res) => {
const signature = req.headers['x-hub-signature-256'] || '';
const raw = JSON.stringify(req.body); // production: use the raw body stream
if (!verifySignature(raw, signature.replace(/^sha256=/, ''), process.env.WEBHOOK_SECRET)) {
return res.status(401).json({ error: 'invalid signature' });
}
// Only enter business logic after verification passes
handleEvent(req.body);
res.status(200).json({ received: true });
});
Handling Failures
On failure, return 401 immediately, skip all business logic, and log the source IP, request headers, and time for auditing. After repeated failures you can temporarily block the source address. Keep secrets in environment variables or a secret manager instead of hard-coding them or committing them to the repo — see environment variables and secrets management. For the wider callback workflow, see the webhook integration guide, and for related API security, check API security and authentication.
Generating a Signature for Local Testing
While developing locally, the platform will not send real requests to localhost. You can generate a valid signature with a one-line OpenSSL command:
# Compute HMAC-SHA256 of a body file with the shared secret
printf '{"event":"payment.succeeded"}' | \
openssl dgst -sha256 -hmac 'your-secret' -hex
# Output looks like sha256=xxxx...
Pair that with ngrok to expose your local port as a public URL, paste it into the platform's webhook config, and you can debug the whole flow end to end.
Frequently Asked Questions
- Why return 401 instead of 404 on failure? A 404 can make the provider think the endpoint is misconfigured and keep retrying; 401 has a clear meaning.
- How should the secret be distributed? Use environment variables or a secret manager, separated per environment, and never commit it.
- What if the same event arrives twice? Verification only answers "is it forged?", not "is it a duplicate?". Deduplicate by event ID on the business side, e.g. record processed event IDs in the database and skip repeats.
- Does verification performance suffer with large bodies? HMAC is O(n); for normal request bodies (a few KB to a few hundred KB) the cost is negligible, so there is nothing to worry about.
References
Reference: GitHub webhook security documentation https://docs.github.com/en/webhooks
Reference: Stripe webhook signature verification https://docs.stripe.com/webhooks/signatures