Tiny, dependency-free TypeScript verifiers for HMAC-signed webhooks. Every provider's scheme is the same primitive - a shared secret, an HMAC over bytes both sides can reconstruct, a constant-time compare - in one of three dialects:
- Raw body - HMAC-SHA256 over the exact request bytes. GitHub's
X-Hub-Signature-256, and the right default when signing webhooks between your own services. - Timestamped -
t=<unix>,v1=<hex>over"<t>.<body>", Stripe-style. The timestamp plus a tolerance window is the replay defense the other two dialects don't have. - URL + sorted params - base64 HMAC-SHA1 over the public webhook URL with
the POST params appended in key order. Twilio's
X-Twilio-Signatureand Mandrill'sX-Mandrill-Signature.
Runs on Node 18+ (node:crypto). No dependencies. Extracted from webhook
receivers I run in production - payment-settlement posts from a card
processor, Mandrill message events, and service-to-service webhooks my own
apps sign. The Twilio verifier is the same construction as Mandrill's with a
different key, included for completeness.
Go twin: webhook-verify-go - same three dialects, and its test suite carries parity vectors generated by this library, so the two produce byte-identical signatures.
- vs.
@octokit/webhooks- that's a webhook framework (event routing, handler registration, a lot of GitHub-specific shape). This is the verification primitive underneath, without the framework, and works for Stripe-style, Twilio, and Mandrill too. Reach for octokit when you want the framework; reach for this when you want the four lines that actually check the signature. - vs. writing it yourself - the mistakes are old and consistent:
verifying re-serialized JSON instead of the raw bytes, using
==against an attacker-supplied signature, forgetting the timestamp tolerance is bidirectional (future clock skew is also an attack), reconstructing the Twilio URL fromreq.urlbehind a proxy. Each of those has a test in this library's suite specifically because I've seen it in the wild. - vs. provider-specific packages -
stripe,twilio, and@mailchimp/mandrillall ship verifiers as part of much larger SDKs. If you only need to verify (a webhook receiver, not an API client), one ~170-LOC zero-dep package for all four providers is a lighter dependency than three SDKs.
npm install @stephenspage/webhook-verifyReleases are published to npm via GitHub Actions with OIDC Trusted Publishing, so every version carries a signed provenance statement.
If you prefer to install straight from GitHub (a prepare hook builds it on
install):
npm install github:JacobStephens2/webhook-verifyThe body you verify must be the unparsed request bytes. Body-parsing middleware that re-serializes JSON destroys the signing base - capture the raw body first.
import { verifyGitHub } from '@stephenspage/webhook-verify';
app.post('/webhooks/github', express.raw({ type: '*/*' }), (req, res) => {
if (!verifyGitHub(req.body, req.get('X-Hub-Signature-256'), process.env.WEBHOOK_SECRET!)) {
return res.status(403).end();
}
const event = JSON.parse(req.body.toString('utf8')); // parse only after verifying
// ...
});For service-to-service webhooks, signBody/verifyBody are the same scheme
without GitHub's sha256= prefix.
import { signTimestamped, verifyTimestamped } from '@stephenspage/webhook-verify';
// Producer - sign what you send:
const header = signTimestamped(payload, secret, Math.floor(Date.now() / 1000));
await fetch(receiverUrl, {
method: 'POST',
headers: { 'Webhook-Signature': header },
body: payload,
});
// Receiver - default tolerance is 300 seconds either direction:
if (!verifyTimestamped(rawBody, req.get('Webhook-Signature'), secret)) {
return res.status(403).end();
}Multiple v1 entries in one header are accepted (sent during key rotation);
any single match passes.
The URL in the signature is the one the provider called - the public
https:// URL, query string included. Behind a reverse proxy your app sees
something else; reconstruct the public URL from configuration, not from the
request.
import { verifyTwilio, verifyMandrill } from '@stephenspage/webhook-verify';
verifyTwilio(
'https://app.example.com/sms/status', // public URL, as configured in Twilio
req.body, // parsed POST params
req.get('X-Twilio-Signature'),
process.env.TWILIO_AUTH_TOKEN!,
);
verifyMandrill(
'https://app.example.com/webhooks/mandrill',
req.body,
req.get('X-Mandrill-Signature'),
process.env.MANDRILL_WEBHOOK_KEY!,
);| Function | Scheme |
|---|---|
signBody(rawBody, secret, { algorithm?, encoding? }) |
HMAC of the raw bytes (default SHA-256 hex) |
verifyBody(rawBody, signature, secret, opts?) |
Verify a raw-body signature |
verifyGitHub(rawBody, header, secret) |
GitHub X-Hub-Signature-256 (sha256=<hex>) |
signTimestamped(rawBody, secret, timestampSeconds) |
Produce t=<unix>,v1=<hex> over "<t>.<body>" |
verifyTimestamped(rawBody, header, secret, { toleranceSeconds?, nowSeconds? }) |
Verify with a replay-tolerance window |
verifyTwilio(url, params, header, authToken) |
Twilio X-Twilio-Signature |
verifyMandrill(url, params, header, webhookKey) |
Mandrill X-Mandrill-Signature |
safeEqual(a, b) |
Constant-time string compare |
Every verifier returns a boolean and treats a missing or malformed header as
false - nothing throws on attacker-controlled input.
- Verify the raw bytes. A signature over the body is a signature over the exact bytes on the wire. Parse after verifying, never before.
- Comparisons are constant-time (
crypto.timingSafeEqual). A plain==against an attacker-supplied signature is a timing oracle. - Only the timestamped dialect defends against replay. A captured raw-body or URL+params request verifies forever; if replays matter, add event-ID deduplication on top.
- Fail closed. An unset secret in your config should reject requests, not skip verification. The dangerous failure mode is the receiver that silently stops checking.
- A valid signature authenticates the sender, nothing more. It doesn't authorize the payload's contents, dedupe retries, or replace TLS.
MIT © Jacob Stephens