---
title: Verification
description: "Verify webhook authenticity with createHmacVerifier or a custom verify function — built-in HMAC verification with constant-time comparison, runtime-agnostic via the Web Crypto API."
type: package
package: "@zap-studio/webhooks"
---

The router ships a built-in HMAC verifier with constant-time comparison, or accepts a custom `verify` function. Its `verify` option is the authenticity gate for incoming requests: it runs after `before` hooks but before payload validation and the route handler, and if it throws, request handling stops. This prevents forged payloads from ever reaching your business logic.

:::warning

Always verify signatures against the **raw request body** (`ctx.rawBody`), exactly as received. The router reads the request body once and keeps the exact bytes in `rawBody` for this purpose. If middleware upstream parses and re-serializes the body first, the bytes change and every signature check fails — or worse, appears to pass against attacker-controlled content.

:::

## Verify HMAC Signatures

Use `createHmacVerifier` from `webhooks` for providers that sign requests with a shared-secret HMAC (GitHub, and many others). It uses the Web Crypto API rather than Node `crypto`, so it works in any runtime that provides `globalThis.crypto.subtle`.

```ts
import { createHmacVerifier, createWebhookRouter } from "@zap-studio/webhooks";

const router = createWebhookRouter({
  verify: createHmacVerifier({
    headerName: "x-hub-signature-256",
    secret: process.env.GITHUB_WEBHOOK_SECRET!,
    algo: "sha256", // optional, defaults to "sha256"
  }),
});
```

Options:

- `headerName` — the request header containing the provider's signature
- `secret` — the shared HMAC secret, as a string
- `algo` — one of `"sha1"`, `"sha256"`, `"sha384"`, `"sha512"`; defaults to `"sha256"`

The verifier:

- imports the secret once at creation time, then reuses the key for every request
- computes the HMAC from `ctx.rawBody`
- normalizes the header value by stripping an algorithm prefix such as `sha256=`, so provider formats like GitHub's work without extra parsing
- compares the expected and received signatures in **constant time**, which prevents timing attacks that recover a signature byte by byte from response-time differences

`createHmacVerifier` throws `VerificationError` immediately at creation time if the Web Crypto API is unavailable or the algorithm is unsupported, and per request when the signature header is missing or the signature does not match.

## Runtime-Agnostic

Uses the Web Crypto API, not Node-specific APIs. `createHmacVerifier` calls `globalThis.crypto.subtle` directly instead of importing Node's `crypto` module, so the same code runs unchanged in Node.js, Bun, Deno, Cloudflare Workers, and browsers.

```ts
// No Node `crypto` import — works wherever globalThis.crypto.subtle exists
const verify = createHmacVerifier({
  headerName: "x-hub-signature-256",
  secret: process.env.WEBHOOK_SECRET!,
});
```

`globalThis.crypto.subtle` is available by default in Bun, Deno, Cloudflare Workers, and browsers over HTTPS. In Node.js, it's global from Node 19 onward (pass `--experimental-global-webcrypto` on Node 18).

## Handle Verification Failures

Import `VerificationError` from `webhooks` to distinguish verification failures from other errors — for example, in an `onError` hook that maps them to a `401` instead of the default `500`.

```ts
import { createHmacVerifier, createWebhookRouter, VerificationError } from "@zap-studio/webhooks";

const router = createWebhookRouter({
  verify: createHmacVerifier({
    headerName: "x-hub-signature-256",
    secret: process.env.GITHUB_WEBHOOK_SECRET!,
  }),
  onError: (error) => {
    if (error instanceof VerificationError) {
      return Response.json({ error: "invalid signature" }, { status: 401 });
    }
    return undefined; // fall back to the default 500 response
  },
});
```

## Write a Custom Verifier

For providers with their own signing scheme, pass any function matching `VerifyFn` — `(ctx: WebhookContext) => Promise<void> | void` — that throws on failure. The context exposes `request` (for headers), `rawBody` (the exact body bytes), and `path`. Stripe, for example, signs a timestamped payload, so verification should use the Stripe SDK.

```ts
import Stripe from "stripe";
import { createWebhookRouter, VerificationError } from "@zap-studio/webhooks";

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);

const router = createWebhookRouter({
  verify: ({ request, rawBody }) => {
    const signature = request.headers.get("stripe-signature");
    if (!signature) {
      throw new VerificationError("Missing Stripe signature");
    }

    try {
      stripe.webhooks.constructEvent(
        Buffer.from(rawBody),
        signature,
        process.env.STRIPE_WEBHOOK_SECRET!,
      );
    } catch {
      throw new VerificationError("Invalid Stripe signature");
    }
  },
});
```

Write a custom verifier when a provider:

- uses a non-HMAC signature format
- requires SDK-specific verification logic
- includes timestamp or replay-protection checks

If your custom verifier compares signatures itself, use `constantTimeEquals` from `webhooks` instead of `===`. It compares byte arrays (`Uint8Array`), not strings — encode strings first.

```ts
import { constantTimeEquals } from "@zap-studio/webhooks";

const encoder = new TextEncoder();
const isValid = constantTimeEquals(
  encoder.encode(expectedSignature),
  encoder.encode(receivedSignature),
);
```

## Per-Route Verification

`verify` also works on `register()`, not just `createWebhookRouter()`. A route-level `verify` overrides the router-level one for that route only — useful when a single router handles multiple providers, each with its own signing scheme:

```ts
import { createHmacVerifier, createWebhookRouter } from "@zap-studio/webhooks";

const router = createWebhookRouter();

router.register("/github", {
  schema: githubEventSchema,
  verify: createHmacVerifier({
    headerName: "x-hub-signature-256",
    secret: process.env.GITHUB_WEBHOOK_SECRET!,
  }),
  handler: async ({ payload }) => {
    console.log("GitHub event:", payload.ref);
  },
});

router.register("/stripe", {
  schema: stripeEventSchema,
  verify: ({ request, rawBody }) => {
    // Stripe's own signing scheme — see "Write a Custom Verifier" above
  },
  handler: async ({ payload }) => {
    console.log("Stripe event:", payload.type);
  },
});
```

No router-level `verify` is needed here — each route carries its own, right next to its `schema` and `handler`. If a router-level `verify` is also set, it only applies to routes that don't set their own; a route-level `verify` always takes priority when present.

:::warning[Replay protection]

An HMAC signature proves who sent a request, not when. An attacker who captures a valid delivery can replay it later and it will still verify. If your provider includes a signed timestamp (as Stripe does), reject requests older than a few minutes; otherwise, track processed event IDs and make your handlers idempotent.

:::
