Skip to content

Repository files navigation

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

About

HMAC webhook signing and verification with replay protection, timestamp validation, and zero runtime dependencies.

Topics

Resources

Security policy

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages