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
secureGeneratecalled 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:
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:
importHmacKeyhands 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,
nullincluded.hmacVerifytreats 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:
| Situation | What happens |
|---|---|
| Signature is missing, wrong, malformed, or not text or bytes | hmacVerify returns false. |
Body was decoded or re-serialized, or returnAs names a different known encoding than the provider's | Also false. Check the integration once with a real delivery. |
Secret is undefined, such as an unset variable | Throws 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's | Throws OUT_OF_RANGE, naming what the key is. |
algorithm or returnAs is a name the library does not know | Throws UNSUPPORTED. |
| The runtime refuses the import or the signature | Throws 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):
| Option | Default | Notes |
|---|---|---|
algorithm | "SHA-256" | Fixed when you import a key. Later calls with that key may only repeat it. |
returnAs (hmac) | mirrors data | The 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 }tohmacandhmacVerify, and the two sides can't drift apart. - Raw bytes:
returnAs: "bytes"gives you the signature as aUint8Array, 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.