npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

webhook-hmac-kit

v2.1.0

Published

Sign and verify webhooks with HMAC-SHA256 on Web Crypto. Timestamp checks, nonce replay protection, secret rotation, Express/Fastify/Nest adapters.

Readme

webhook-hmac-kit

npm version license

Sign and verify webhook requests with HMAC-SHA256: timestamp validation, nonce-based replay protection, secret rotation, and adapters for Express, Fastify and NestJS.

demo

Runs on Web Crypto only, so it works the same way on Node, Cloudflare Workers, Deno and Bun with no bundler configuration and no runtime branch.

2.0.0 is a breaking release. The wire format changed. See CHANGELOG.md before upgrading in place: a v1 signature is refused outright, with no dual-accept window.

Install

npm install webhook-hmac-kit

Quick Start

import { signWebhook, verifyWebhook } from 'webhook-hmac-kit';

// --- Sender ---
const payload = JSON.stringify({ event: 'payment.completed', amount: 4999 });
const timestamp = Math.floor(Date.now() / 1000);
const nonce = crypto.randomUUID();

const { signature } = await signWebhook({ secrets: 'whsec_your_secret_key', payload, timestamp, nonce });

// Send the payload with these three headers, built from the same values you just signed:
//   x-webhook-signature: signature
//   x-webhook-timestamp: String(timestamp)
//   x-webhook-nonce:     nonce

// --- Receiver ---
const result = await verifyWebhook({
  secrets: 'whsec_your_secret_key',
  payload: req.body,                         // the exact bytes you received, see below
  signature: req.headers['x-webhook-signature'],
  timestamp: Number(req.headers['x-webhook-timestamp']),
  nonce: req.headers['x-webhook-nonce'],
});
// result.valid === true (throws a typed error on failure)

signWebhook is not async: it validates its arguments synchronously, so a bad one throws immediately, and returns a plain Promise for the HMAC step alone. verifyWebhook is async throughout.

payload must be the exact bytes that went on the wire, not a re-serialised object. Configure your framework to hand you the raw body (express.raw(), Fastify's rawBody, NestJS's rawBody: true) and pass that straight through. Never JSON.parse then JSON.stringify before verifying: that changes key order and whitespace and breaks the signature. Verify first, parse second.

Wire Format

The signed value is:

v2.{timestamp}.{nonce}.{payload}

built as bytes: the v2.{timestamp}.{nonce}. prefix is UTF-8 encoded, and the payload follows unchanged, as the exact bytes it arrived in if you passed a Uint8Array, or UTF-8 encoded if you passed a string. The signature header carries the version and a lower-case hex digest, nothing else:

x-webhook-signature: v2=<64 lower-case hex characters>

Upper-case hex is rejected. A sender in another language must format the digest with %x, not %X.

  • Nonce must match ^[A-Za-z0-9_-]{1,64}$, checked before any HMAC work runs. Dot-free by construction: the dot is the field delimiter, so a nonce that could contain one would make the encoding ambiguous.
  • Timestamp is Unix seconds, a non-negative integer, matching ^(0|[1-9]\d*)$ on the wire: no leading zeros, no +, no exponent form, no whitespace. The value that gets checked is the value the sender actually signed, not whatever Number() coerces.
  • Tolerance defaults to 300 seconds, checked as |now - timestamp| <= tolerance. Pass tolerance to verifyWebhook to change it.

Rotation

await verifyWebhook({ secrets: [currentSecret, retiringSecret], ...rest });

Sign with one secret. Verify against a list: a signature made with any entry in it is accepted. Keep the new secret first and the retiring one after it while both are live, then drop the old one. A list may hold at most 16 distinct secrets; duplicates are collapsed before that cap is applied.

Replay Protection

await verifyWebhook({
  ...rest,
  nonceValidator: async (nonce) => {
    const key = `webhook:nonce:${nonce}`;
    if (await redis.exists(key)) return false;
    await redis.set(key, '1', 'EX', 300);
    return true;
  },
});

This library does not store anything. It makes the nonce a safe cache key, dot-free, bounded length, and calls your nonceValidator after the signature has already checked out, never before. Replay protection is exactly as strong as the store behind that callback: its TTL needs to be at least your tolerance window, and it needs to be shared across every receiving instance, or a nonce accepted on one instance replays cleanly on another.

Adapters

All three need the raw request body, same as the core functions.

Express

import { webhookVerifier } from 'webhook-hmac-kit/express';

app.post(
  '/webhook',
  express.raw({ type: 'application/json' }),  // req.body must stay bytes here
  webhookVerifier({
    secrets: [process.env.WEBHOOK_SECRET_CURRENT, process.env.WEBHOOK_SECRET_OLD],
    onError: (err) => logger.warn('webhook rejected', err),
  }),
  (req, res) => {
    // req.webhookVerified === true; req.body is still bytes, parse it yourself.
    res.sendStatus(200);
  },
);

Fastify

import fastifyRawBody from 'fastify-raw-body';
import { webhookPlugin } from 'webhook-hmac-kit/fastify';

await app.register(fastifyRawBody);
await app.register(webhookPlugin, { secrets: process.env.WEBHOOK_SECRET });

app.post('/webhook', { preHandler: app.verifyWebhook }, async (request) => {
  return { ok: true };
});

NestJS

// main.ts: request.rawBody needs this at bootstrap
const app = await NestFactory.create(AppModule, { rawBody: true });

// webhook.controller.ts
import { UseGuards, Post } from '@nestjs/common';
import { WebhookGuard } from 'webhook-hmac-kit/nest';

@UseGuards(WebhookGuard)
@Post('webhook')
handleWebhook() {
  return { ok: true };
}

WebhookModule.forRoot({ secrets: ... }) registers WEBHOOK_OPTIONS and WebhookGuard in one call, but WebhookGuard takes its options as a constructor argument, so it is not injectable as a bare class provider: Nest has no way to resolve that argument on its own, and construction fails. Provide it with a factory instead:

import { WebhookGuard, WEBHOOK_OPTIONS } from 'webhook-hmac-kit/nest';

providers: [
  { provide: WEBHOOK_OPTIONS, useValue: { secrets: process.env.WEBHOOK_SECRET } },
  { provide: WebhookGuard, useFactory: (options) => new WebhookGuard(options), inject: [WEBHOOK_OPTIONS] },
],

or skip Nest's container for the guard entirely and construct it yourself.

Nest exception note. WebhookGuard throws its own local exception class, because this library has no dependency on @nestjs/common and so cannot throw its HttpException. Nest's BaseExceptionFilter matches by instanceof against its own class, so a global exception filter renders this as a 500, not the intended 401, unless you catch and re-map it: the intended status is on .getStatus(), the real reason is in onError either way.

Standard Webhooks

Some providers sign with Standard Webhooks rather than a scheme of their own, so the package ships a signer and a verifier for it alongside its own. Three functions, no new dependency, no adapter changes.

import { signStandardWebhooks, verifyStandardWebhooks } from 'webhook-hmac-kit';

const headers = await signStandardWebhooks({
  secrets: 'whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw',
  messageId: 'msg_p5jXN8AQM9LWM0D4loKWxJek',
  timestamp: Math.floor(Date.now() / 1000),
  payload: body,
});
// { 'webhook-id': …, 'webhook-timestamp': …, 'webhook-signature': 'v1,<base64>' }

await verifyStandardWebhooks({ secrets, headers: req.headers, payload: rawBody });

verifyStandardWebhooks resolves to { valid: true } or throws the same WebhookError subclasses the rest of the library throws, so the error handling below applies unchanged — for anything that is a failed verification. Two kinds of failure are not: a mistake in your own arguments (a tolerance that is not a non-negative finite number, a secret outside the base64 grammar) throws a plain Error or TypeError, and a runtime whose Web Crypto misbehaves throws a plain Error too, the same way WebCryptoUnavailableError does. Neither is a WebhookError, so the adapters answer them with 500 rather than 401: your configuration or your machine is wrong, not the caller's signature.

Header names are matched case-insensitively, a single-entry array is unwrapped, an empty string counts as missing, and two values for one header are refused rather than resolved. Secrets are whsec_-prefixed base64, or a Uint8Array of raw key bytes; parseStandardWebhooksSecret is exported if you want the bytes. A secret whose base64 length leaves a remainder of 1 is rejected here, where the upstream JavaScript library decodes it anyway and silently gives you a key one character shorter than the one you configured.

Never use one secret for both schemes. v2.{ts}.{nonce}.{payload} and a Standard Webhooks message whose id is the literal v2 and whose payload is {nonce}.{payload} are byte-identical, so a signature minted under one scheme is a valid signature under the other. Domain separation would close it and conformance forbids domain separation, so the rule is operational: generate a separate secret. A test pins the collision.

webhook-id is not a trust boundary. Their signed value is {id}.{timestamp}.{payload} with nothing constraining the id, so an id holding a dot and a run of digits re-splits into a different, equally valid message carrying the same signature. It cannot be fixed without emitting signatures no conforming receiver would accept, so this module does not try, and gives you no replay hook keyed on it. If you need replay protection with a key you can actually trust, use this package's own scheme, where the nonce is dot-free by construction.

What differs on the way out and on the way in

| | Signing | Verifying | |---|---|---| | Key length | 24–64 bytes, the spec's stated range | any non-empty key | | Payload | must be well-formed UTF-8 | hashed as bytes; a string payload is UTF-8 encoded first | | Message id | no ., no whitespace | anything non-empty | | Signature entries | one v1,<base64> per secret, space-joined | unknown tags and malformed entries skipped |

A verifier weighs at most 16 signature entries, the same cap that bounds the secret list, because the entry count arrives from the network and every entry is weighed against every configured secret. A conforming sender emits one entry per live key, so nothing legitimate reaches it.

Each asymmetry is the lenient side facing the network. No reference library enforces the key range, and upstream's own Python suite signs with a 23-byte key, so refusing a short key on receive would break a live integration to make a point. Four reference libraries disagree about a body that is not well-formed UTF-8 — Go signs the bytes, Rust refuses the message, JavaScript and Python sign a mangled copy — so there is no digest that satisfies all of them and the emitter refuses rather than producing one some receivers compute differently; the verifier has no such problem and takes the bytes as they arrive, which is Go's behaviour. Skipping unreadable signature entries is what their rotation model needs and what their own tests require: they put a v2, entry beside a valid one and expect the request to succeed.

What the interop claim covers

This implementation reproduces the de-facto vector shared by six reference implementations — the JavaScript, Go, Python, Ruby, PHP and C# test suites all pin the same one — plus the Rust crate's own vector, both signing and verifying, byte for byte. The two counts above describe different sets: six suites pin that vector, and the four libraries whose payload handling was read at source (Go, Rust, JavaScript, Python) are the ones that disagree about non-UTF-8 bodies. Both are committed in test/standard-webhooks-vectors.ts with the upstream commit and path they came from.

It does not cover: svix-* alias headers, the asymmetric v1a ed25519 tag, parsing the payload as JSON (this library never parses a payload), or a body that is not well-formed UTF-8, where the reference implementations do not agree with each other and so no single behaviour can be conformant. There is no official conformance suite to point at; those two vectors are what exists.

If your provider still sends the older svix-id, svix-timestamp and svix-signature names, map them onto the webhook-* names before calling: the values are identical, and verifyStandardWebhooks looks only for the specified names.

Error Handling

Every verification failure throws a typed error. Adapters answer every one of them with the same status and body:

| Error class | Code | |---|---| | WebhookSignatureError | WEBHOOK_SIGNATURE_INVALID | | WebhookTimestampError | WEBHOOK_TIMESTAMP_EXPIRED / WEBHOOK_TIMESTAMP_INVALID | | WebhookNonceError | WEBHOOK_NONCE_REPLAYED / WEBHOOK_NONCE_INVALID |

All three extend WebhookError, so catch (err) { if (err instanceof WebhookError) } is enough to tell a verification failure from anything else. WebCryptoUnavailableError, thrown when the runtime has no Web Crypto, deliberately does not extend it: it means the receiver is broken, not that the request failed to verify, so adapters answer it with 500 instead of 401.

Threat Model

  • The signature covers the exact bytes on the wire, end to end. Pass the raw bytes you sent or received: a Uint8Array, or the exact string, never a re-parsed and re-serialised object, and never a body decoded to a string and then discarded for something else. Getting this wrong used to be able to make two different payloads verify against one signature (fixed in 2.0.0, see the CHANGELOG).
  • Replay protection is exactly as strong as the nonceValidator you supply. This library never caches anything itself; it only makes the nonce a safe cache key. A store with a TTL shorter than your tolerance window, one that fails open, or one that isn't shared across instances gives you no replay protection at all.
  • This is integrity and authenticity only. There is no confidentiality (HTTPS is required and is not checked here), no protection once the shared secret leaks, and no payload size limit: enforce that at your HTTP layer. Every verification failure answers the same 401 with the same body on purpose; the specific reason is only available through onError, never on the wire.

Runtime Support

Node ≥22, Cloudflare Workers, Deno, Bun. Vercel Edge is not supported: its runtime is being wound down (Next.js 16.3 removed runtime: 'edge').

globalThis.crypto.subtle must exist. It does on all of the above by default. A Node process started with --no-experimental-global-webcrypto does not have it: drop that flag, or install the global yourself before importing this library:

import { webcrypto } from 'node:crypto';
globalThis.crypto ??= webcrypto;

(in CommonJS, the same fix is globalThis.crypto ??= require('node:crypto').webcrypto).

There is no fallback inside this library: a literal node:crypto import gets resolved at bundle time by esbuild, wrangler and Metro whether or not the surrounding code can run it, so a guarded import would cost every bundled build for nothing.

Signature Comparison

Verification compares digests with a Double-HMAC blind rather than trusting the runtime's own constant-time primitive: draw a random key, HMAC both the expected and the presented digest under it, and compare those results instead. This exists because constant-time HMAC verification is only required by the Web Crypto editor's draft (w3c/webcrypto PR #553), not by any published Recommendation, and Node itself shipped a plain memcmp in its own HMAC verify path until CVE-2026-21713 was patched (v20.20.2, v22.22.2, v24.14.1, v25.8.2). Since this library does not control which patch level a caller runs, it does not rely on the host's compare being constant-time in the first place. Defence in depth: no exploit of the underlying Node bug is demonstrated in the sources above. Cost: 3 subtle.sign and 2 subtle.importKey calls per configured secret to verify, 1 importKey and 1 sign to sign, fine for a webhook receiver, so don't put a 16-entry rotation list on a request path that isn't one.

Test Vectors

All vectors below use secret whsec_test_secret_key_1234567890, SIGNATURE_VERSION v2 and timestamp 1700000000. The full set, with the canonical string for each text payload, is in test/vectors.ts.

| Name | Nonce | Payload | Signature | |---|---|---|---| | basic JSON | nonce_abc123 | {"event":"payment.completed","amount":4999} | v2=e797b4fdd2f6b2f3055a9ecc45985389a3458f113e4da5c7242e2aec2d733887 | | empty payload | nonce_empty001 | (empty) | v2=048213db0c13dc805ae0e9242ce377d23756eb5c6103f08c18e0b9301ff277fa | | unicode payload | nonce_unicode01 | {"name":"Héllo Wörld","emoji":"🚀"} | v2=51bc5b40b150cfb802e6a1e806b69a1e6bbe1447d020aa2a0e9053d6bbc985d2 | | byte payload, not valid UTF-8 | nonce_bytes001 | 7b ff 7d (hex) | v2=6bcc8aabb3021f06f7cb713985154d03ca3916082148444bd0cc75e3837cd430 |

License

MIT