hmac() / hmacVerify()

Sign a message with a shared secret, and check a signature in constant time without ever throwing on the attacker's input.

import { hmac, hmacVerify } from "unsecure/hmac";

const signature = await hmac("secret-key", "payload");
// "10aa2e1c2538464ff75f0647271e3ba746bca3fcdeaf322c581bf5851e8cddb7"

await hmacVerify("secret-key", "payload", signature); // true

#Signatures

function importHmacKey(
  secret: string | BytesSource,
  options?: { algorithm?: "SHA-1" | "SHA-256" | "SHA-384" | "SHA-512" },
): Promise<CryptoKey>;

function hmac(
  secret: string | BytesSource | CryptoKey,
  data: string | BytesSource,
  options?: { algorithm?: DigestAlgorithm; returnAs?: DigestReturnAs },
): Promise<string | Uint8Array>;

function hmacVerify(
  secret: string | BytesSource | CryptoKey,
  data: string | BytesSource,
  signature: string | BytesSource | null | undefined,
  options?: { algorithm?: DigestAlgorithm; returnAs?: DigestReturnAs },
): Promise<boolean>;

#Options

OptionTypeDefaultWhat it does
algorithmDigestAlgorithm"SHA-256"The digest under the MAC. Matched case-insensitively.
returnAsDigestReturnAsmirrors the type of dataFor hmac, the shape of the result. For hmacVerify, how to read a string signature.

#Return shape

hmac follows the same rule as hash: a string in gives hex out, bytes in give bytes out, an explicit returnAs wins.

await hmac("secret-key", "payload", { returnAs: "base64" });
// "EKouHCU4Rk/3XwZHJx47p0a8o/zerzIsWBv1hR6M3bc="

hmacVerify returns a plain boolean.

#How verification reads the signature

hmacVerify computes the MAC once as raw bytes, then compares with secureCompare in constant time.

  • A BytesSource signature is compared as it is.
  • A string signature is decoded strictly with the codec named by returnAs, which is the format hmac() would have produced for the same options. returnAs: "uint8array", "bytes", or no returnAs at all reads a string signature as hex.

So one options object serves both calls:

const options = { algorithm: "SHA-512", returnAs: "base64" } as const;

const sig = await hmac(secret, body, options);
await hmacVerify(secret, body, sig, options); // true

#Verification never throws on the request

Untrusted input fails, it does not explode:

await hmacVerify("secret-key", "payload", null); // false — the header was missing
await hmacVerify("secret-key", "payload", "not hex"); // false — not a canonical encoding
await hmacVerify("secret-key", "payload", 12_345 as any); // false — not text or bytes
await hmacVerify("secret-key", "payload", "00".repeat(32)); // false — wrong signature

The one thing that does throw is a problem with your own deployment:

await hmacVerify("", "payload", sig);
// UnsecureError OUT_OF_RANGE: hmac: secret must not be empty.

That is the point of the split. A secret that failed to load must not read as a wrong signature, because it would look like an attack instead of an outage.

#Reusing an imported key

Raw bytes are imported into Web Crypto on every hmac() call. importHmacKey() does that work once and hands back a non-extractable, sign-only CryptoKey that both functions accept in place of the secret.

import { hmacVerify, importHmacKey } from "unsecure/hmac";

// Once, at startup
const key = await importHmacKey(process.env.WEBHOOK_SECRET);

// Per request — no importKey of its own
const valid = await hmacVerify(key, body, request.headers.get("x-signature"));

The hash is fixed at import time, so a key made for SHA-256 signs only SHA-256. Passing algorithm alongside a key is allowed only when it names that same hash:

await hmac(key, "payload", { algorithm: "SHA-512" });
// UnsecureError OUT_OF_RANGE: hmac: algorithm must match the key's hash SHA-256, got "SHA-512".

A key that is not an HMAC key with the sign usage is OUT_OF_RANGE too, and the message names what it actually is.

#Errors

Causecode
secret or data is neither text, bytes nor a CryptoKeyINVALID_TYPE
An empty secretOUT_OF_RANGE
A CryptoKey of the wrong kind, or a hash that does not matchOUT_OF_RANGE
An algorithm or returnAs name the library does not knowUNSUPPORTED
Web Crypto refused importKey or signPLATFORM

A malformed signature is never an error. It is a false.

#Recipe: verify a webhook

import { hmacVerify } from "unsecure/hmac";
import { randomJitter } from "unsecure/random";

export async function handleWebhook(request: Request) {
  const body = await request.text();
  const valid = await hmacVerify(
    process.env.WEBHOOK_SECRET,
    body,
    request.headers.get("x-signature"),
  );

  // Defence in depth: the response time says nothing about where the check failed
  await randomJitter(10, 50);

  if (!valid) return new Response("Forbidden", { status: 403 });
  return process(JSON.parse(body));
}

Verify the raw body text, not a re-serialized object. JSON.stringify(JSON.parse(body)) is not guaranteed to reproduce the bytes the sender signed.

Some providers send the signature base64-encoded, or prefixed. Decode the wrapper yourself and hand hmacVerify the bare signature with the matching returnAs:

const header = request.headers.get("x-signature") ?? "";
const signature = header.startsWith("sha256=") ? header.slice(7) : header;
await hmacVerify(secret, body, signature); // hex is the default

#Pitfall: comparing with ===

// Short-circuits on the first differing character, which leaks timing
if (computed === received) {}

// Constant-time
if (await hmacVerify(secret, data, received)) {}

#Pitfall: a returnAs that does not match the signature

const sig = await hmac(secret, data, { returnAs: "base64" });

await hmacVerify(secret, data, sig); // false — read as hex
await hmacVerify(secret, data, sig, { returnAs: "base64" }); // true

The mismatch verifies as false rather than throwing, because a signature is untrusted input as far as the library is concerned. Test the happy path once and you will catch it.