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
| Option | Type | Default | What it does |
|---|---|---|---|
algorithm | DigestAlgorithm | "SHA-256" | The digest under the MAC. Matched case-insensitively. |
returnAs | DigestReturnAs | mirrors the type of data | For 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
BytesSourcesignature is compared as it is. - A string signature is decoded strictly with the codec named by
returnAs, which is the formathmac()would have produced for the same options.returnAs: "uint8array","bytes", or noreturnAsat 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 signatureThe 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
| Cause | code |
|---|---|
secret or data is neither text, bytes nor a CryptoKey | INVALID_TYPE |
An empty secret | OUT_OF_RANGE |
A CryptoKey of the wrong kind, or a hash that does not match | OUT_OF_RANGE |
An algorithm or returnAs name the library does not know | UNSUPPORTED |
Web Crypto refused importKey or sign | PLATFORM |
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" }); // trueThe 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.