
# hmac() / hmacVerify()

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

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

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

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

## Signatures

```ts
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`](/crypto/hashing/hash): a `string` in gives hex out, bytes in give bytes out, an explicit `returnAs` wins.

```ts
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`](/generate/secrets/compare) 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:

```ts
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:

```ts
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:

```ts
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.

```ts
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:

```ts
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

```ts
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`:

```ts
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 `===`

```ts
// 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

```ts
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.
