
# Verify a webhook

> Check that a request really came from the provider that claims to have sent it.

## The shape of it

A provider signs the request body with a secret you both hold and puts the signature in a header. You recompute the MAC over the **raw body bytes** and compare.

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

export async function handleWebhook(request: Request) {
  const body = await request.text(); // raw text, before any JSON.parse
  const signature = request.headers.get("x-signature");

  const valid = await hmacVerify(process.env.WEBHOOK_SECRET, body, signature);

  await randomJitter(10, 50);

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

`hmacVerify` compares in constant time and returns `false` for a missing header, a signature that is not canonical hex, or a value that is not text at all. There is nothing to catch here.

## Signature formats

Providers wrap the signature differently. Strip the wrapper yourself, then tell `hmacVerify` how to read what is left.

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

// A bare base64 signature
await hmacVerify(secret, body, header, { returnAs: "base64" });

// SHA-512, base64url
await hmacVerify(secret, body, header, { algorithm: "SHA-512", returnAs: "base64url" });
```

`returnAs` names the format `hmac()` would have produced. Get it wrong and the check returns `false` rather than throwing, so test the happy path once.

## Signing over more than the body

Many providers sign a constructed string, usually to bind a timestamp into the signature. Build the same string on your side.

```ts
const timestamp = request.headers.get("x-timestamp") ?? "";
const signedPayload = `${timestamp}.${body}`;

// Reject old requests before checking the signature: a valid signature on a
// replayed request is still a replay
const age = Math.abs(Date.now() / 1000 - Number(timestamp));
if (!Number.isFinite(age) || age > 300) return new Response("Stale", { status: 400 });

const valid = await hmacVerify(secret, signedPayload, signature);
```

## Importing the key once

A busy endpoint imports the same secret into Web Crypto on every request. Do it once instead.

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

const key = await importHmacKey(process.env.WEBHOOK_SECRET);

export async function handleWebhook(request: Request) {
  const body = await request.text();
  const valid = await hmacVerify(key, body, request.headers.get("x-signature"));
  // …
}
```

The key is non-extractable and sign-only, and its hash is fixed at import time.

## Rotating the secret

Accept either secret during the overlap, and check both every time so the timing does not say which one matched.

```ts
const [oldValid, newValid] = await Promise.all([
  hmacVerify(previousKey, body, signature),
  hmacVerify(currentKey, body, signature),
]);
if (!(oldValid || newValid)) return new Response("Forbidden", { status: 403 });
```

## What to get right

- **Verify the raw bytes.** `JSON.stringify(JSON.parse(body))` is not guaranteed to reproduce what the sender signed. Read the body as text first, and parse only after the check passes.
- **Do not compare with `===`.** That is what [`secureCompare`](/generate/secrets/compare) exists for, and `hmacVerify` uses it internally.
- **Handle the empty secret separately.** An unset `WEBHOOK_SECRET` throws `OUT_OF_RANGE`, and it should page you rather than return a 403.

```ts
import { UnsecureError } from "unsecure/errors";

try {
  const valid = await hmacVerify(secret, body, signature);
  if (!valid) return new Response("Forbidden", { status: 403 });
} catch (error) {
  if (error instanceof UnsecureError && error.code === "OUT_OF_RANGE") {
    logger.error("WEBHOOK_SECRET is not configured");
    return new Response("Server error", { status: 500 });
  }
  throw error;
}
```
