Sign and verify messages

Sign a message with a shared secret, and check an incoming signature without a forged or missing one ever throwing.

An HMAC is a stamp you can only make if you know a secret. The sender stamps exactly what it sends; you stamp what arrived and compare. Same stamp means nobody touched the message on the way and the sender really has the secret. That's what a webhook signature is. Concepts explains why the comparison takes the same time whatever it's given.

#Before you start

  • Node.js 22.13 or later (24 or later on the 24 line), or any runtime with the Web Crypto API.
  • Both sides hold the same secret, kept in the environment and never sent with the message. Generate it once with secureGenerate called with { length: 43, specials: false }, which gives 256 random bits as letters and digits.
  • Verify the raw bytes of the body, read with await request.arrayBuffer(). request.text() decodes them as UTF-8 first, and re-serializing parsed JSON changes spacing and escapes. One changed byte fails the check.
  • An HMAC proves origin, not freshness: a captured delivery verifies again an hour later. If that matters, sign a timestamp with the body and refuse old ones.

#Sign and receive a webhook

One module covers both sides: it stamps the deliveries you send and checks the ones you receive, with the secret loaded once:

sign-messages.ts
import { hmac, hmacVerify, importHmacKey } from "unsecure/hmac";

const secret = process.env.WEBHOOK_SECRET;
if (!secret) throw new Error("WEBHOOK_SECRET is not set");

const key = await importHmacKey(secret);

/** Headers for an outgoing delivery, signed over the exact body you send. */
export async function signDelivery(body: string): Promise<Headers> {
  const signature = await hmac(key, body);
  return new Headers({ "x-webhook-signature": `sha256=${signature}` });
}

type Handler = (event: unknown) => Promise<void>;
/** Hands the event on only when its signature matches the raw bytes. */
export async function receiveDelivery(request: Request, handle: Handler): Promise<Response> {
  const body = await request.arrayBuffer();
  const header = request.headers.get("x-webhook-signature");
  const signature = header?.startsWith("sha256=") ? header.slice(7) : null;
  if (!(await hmacVerify(key, body, signature))) {
    return new Response("Invalid signature", { status: 401 });
  }
  await handle(JSON.parse(new TextDecoder().decode(body)));
  return new Response(null, { status: 204 });
}
  • Import once: importHmacKey hands the secret to Web Crypto at start-up, so no request pays for that step again. Nothing can export the key back to bytes.
  • Fail at start-up: if the variable is missing, the process stops before it serves anything, rather than failing on the first delivery.
  • Untrusted header: hand the header over as it arrives, null included. hmacVerify treats it as untrusted input.
  • Prefix: sha256= is the provider's label, not part of the signature, so cut it off before verifying.

#Match the provider's signature format

Providers write the same signature in different alphabets. hmacVerify reads a string signature as hex unless returnAs names another encoding, so a base64 provider needs the option:

const valid = await hmacVerify(key, body, signature, { returnAs: "base64" });

Decoding is strict: a string that isn't the canonical spelling of some bytes verifies as false. A signature passed as bytes is compared as it is. Pick a known encoding that doesn't match the provider's and every delivery verifies as false, so try one real delivery before you ship. An encoding name the library doesn't know throws UNSUPPORTED.

#When it fails

The first two rows return false, and the rest throw:

SituationWhat happens
Signature is missing, wrong, malformed, or not text or byteshmacVerify returns false.
Body was decoded or re-serialized, or returnAs names a different known encoding than the provider'sAlso false. Check the integration once with a real delivery.
Secret is undefined, such as an unset variableThrows INVALID_TYPE.
Secret is ""Throws OUT_OF_RANGE, so a secret that failed to load never reads as a bad signature.
The key is not an HMAC signing key, or algorithm names a different hash from the key'sThrows OUT_OF_RANGE, naming what the key is.
algorithm or returnAs is a name the library does not knowThrows UNSUPPORTED.
The runtime refuses the import or the signatureThrows PLATFORM, with the runtime's error as cause.

Errors lists every code and how to handle it.

#Options

The three functions share these options, hmac(secret, data, options), hmacVerify(secret, data, signature, options) and importHmacKey(secret, options):

OptionDefaultNotes
algorithm"SHA-256"Fixed when you import a key. Later calls with that key may only repeat it.
returnAs (hmac)mirrors dataThe output shape: hex for a string, a Uint8Array for bytes.
returnAs (hmacVerify)"hex"How a string signature is decoded, whatever data is. A bytes signature ignores it.

secret is text, bytes or a key from importHmacKey. The library reads text as UTF-8.

#Going further

Rotation, shared options and raw bytes come up once signatures travel between teams or services:

  • Rotating the secret: import the old and the new key, accept a delivery when either one verifies, and sign with the new one. Once every sender has switched, drop the old key.
  • One options object: pass the same { algorithm, returnAs } to hmac and hmacVerify, and the two sides can't drift apart.
  • Raw bytes: returnAs: "bytes" gives you the signature as a Uint8Array, for protocols that pack it into their own format.
  • JWT and signed claims: see what this library is not.
  • A provider integration end to end: the webhook verification recipe walks through one.