Back to Blog
webhooksedgecloudflare-workerstypescriptsecurity

Validating Webhook Signatures in Edge Runtimes: Cloudflare Workers, Vercel Edge, and Deno Deploy

Edge runtimes don't have Node's crypto module, so the HMAC verification patterns you know don't work out of the box. Here's how to verify webhook signatures correctly using only the Web Crypto API, with zero timing vulnerabilities.

A
Aleksa Vukovic
Developer Relations
April 29, 2026
9 min read

If you've moved a webhook consumer to Cloudflare Workers, Vercel Edge Functions, or Deno Deploy, you've run into the same problem: crypto.createHmac doesn't exist. Neither does crypto.timingSafeEqual. The Node.js crypto module is not available in V8-based edge runtimes.

What is available is the Web Crypto API, globalThis.crypto.subtle. It provides all the primitives you need to verify HMAC-SHA256 signatures — but the API looks nothing like Node's crypto module. It's async-first, uses ArrayBuffer and Uint8Array everywhere, and requires you to import key material explicitly before using it.

This post covers the complete picture: how to import a key, compute and verify HMAC-SHA256, handle the replay window, and put it all into a production-ready Cloudflare Worker handler. The same patterns apply to Vercel Edge Functions and Deno Deploy with minor environment differences noted along the way.


Why Edge Runtimes Are Different

Cloudflare Workers, Vercel Edge Functions, and Deno Deploy all run V8 isolates rather than full Node.js processes. Isolates are lightweight by design — they don't expose the operating system, Node.js built-ins, or the CommonJS module system.

What they do expose is the WinterCG minimum common API, a spec maintained by browser vendors and edge platform operators that defines which Web APIs a conforming runtime must provide. The Web Crypto API (crypto.subtle) is part of that spec.

The key differences from Node.js crypto:

CapabilityNode.js cryptoWeb Crypto (crypto.subtle)
HMAC computationcreateHmac(alg, key).update(data).digest()subtle.sign("HMAC", key, data) (async)
Key handlingPass raw Buffer or stringMust call subtle.importKey() first
Timing-safe comparecrypto.timingSafeEqual(a, b)Use subtle.verify() directly
Binary buffersBufferUint8Array / ArrayBuffer
Execution modelSynchronous availableFully async (Promise-based)

The async requirement is the sharpest edge. Every call to importKey, sign, and verify returns a Promise. A missing await silently produces a Promise object where you expected bytes — and your verification logic produces incorrect results without throwing.


Importing the Signing Key

In Node.js you pass a raw string or Buffer directly to createHmac. In Web Crypto, you must import key material as a CryptoKey object before any cryptographic operations:

typescript
async function importHmacKey(secret: string): Promise<CryptoKey> {
  const keyBytes = new TextEncoder().encode(secret);

  return crypto.subtle.importKey(
    "raw",           // format: raw bytes
    keyBytes,        // the actual key material
    { name: "HMAC", hash: "SHA-256" },
    false,           // not extractable — can't read the key back out
    ["sign", "verify"]
  );
}

TextEncoder.encode() replaces Buffer.from(secret) in edge environments. It returns a Uint8Array, which is what importKey expects for raw key material.

Cache the imported key. Calling importKey on every request adds overhead you don't need — your signing secret doesn't change per request. In Cloudflare Workers, module-level state persists for the lifetime of the isolate, so you can cache the CryptoKey in a module-scope variable and pay the import cost only once per cold start.


Verifying the Signature

With the CryptoKey in hand, use crypto.subtle.verify() for HMAC verification. This is intentionally timing-safe by the Web Crypto specification — you don't need a separate constant-time comparison step.

Here's a complete verification function that handles the Stripe-compatible t=<timestamp>,v1=<hex> header format:

typescript
const REPLAY_WINDOW_SECONDS = 300; // reject events older than 5 minutes

async function verifyWebhookSignature(
  request: Request,
  key: CryptoKey
): Promise<Uint8Array> {
  const sigHeader = request.headers.get("webhook-signature");
  if (!sigHeader) {
    throw new Error("Missing Webhook-Signature header");
  }

  // Parse "t=1714561200,v1=abc123..."
  const fields: Record<string, string> = {};
  for (const part of sigHeader.split(",")) {
    const idx = part.indexOf("=");
    if (idx !== -1) {
      fields[part.slice(0, idx)] = part.slice(idx + 1);
    }
  }

  const timestamp = fields["t"];
  const hexSig = fields["v1"];
  if (!timestamp || !hexSig) {
    throw new Error("Malformed Webhook-Signature header");
  }

  // Replay window: reject events with stale timestamps
  const age = Math.floor(Date.now() / 1000) - parseInt(timestamp, 10);
  if (age < 0 || age > REPLAY_WINDOW_SECONDS) {
    throw new Error(`Signature timestamp out of tolerance window (age=${age}s)`);
  }

  // Read the raw body — this consumes the stream, so do it once
  const rawBody = await request.arrayBuffer();
  const bodyBytes = new Uint8Array(rawBody);

  // Build the signed message: "<timestamp>.<body>"
  const encoder = new TextEncoder();
  const prefix = encoder.encode(`${timestamp}.`);
  const message = new Uint8Array(prefix.byteLength + bodyBytes.byteLength);
  message.set(prefix, 0);
  message.set(bodyBytes, prefix.byteLength);

  // Convert hex signature to bytes
  const sigBytes = hexToUint8Array(hexSig);

  // subtle.verify is timing-safe by spec
  const valid = await crypto.subtle.verify("HMAC", key, sigBytes, message);
  if (!valid) {
    throw new Error("Invalid signature");
  }

  return bodyBytes; // return raw bytes so caller can decode JSON separately
}

function hexToUint8Array(hex: string): Uint8Array {
  const bytes = new Uint8Array(hex.length / 2);
  for (let i = 0; i < hex.length; i += 2) {
    bytes[i / 2] = parseInt(hex.slice(i, i + 2), 16);
  }
  return bytes;
}

Three details that matter:

Body reading is one-shot. In edge runtimes, request.body is a ReadableStream. Once consumed by arrayBuffer(), the stream is exhausted. You cannot call request.json() afterward to parse the payload. Read raw bytes first, then decode with new TextDecoder().decode(bytes) after verification passes.

Concatenation without Buffer.concat. Building the signed message (timestamp + "." + body) requires assembling typed arrays manually. Create a new Uint8Array sized to the total length, then set() each piece in sequence. This approach is portable across all edge runtimes.

subtle.verify() not subtle.sign() plus ===. It may be tempting to call sign() and compare the resulting bytes with the provided signature. Don't. That comparison would need to be constant-time, and the Web Crypto spec only guarantees timing safety for verify(). Use verify() directly.


A Complete Cloudflare Worker Handler

Putting the pieces together into a deployable worker:

typescript
interface Env {
  WEBHOOK_SECRET: string;
}

let cachedKey: CryptoKey | null = null;

async function getKey(secret: string): Promise<CryptoKey> {
  if (!cachedKey) {
    cachedKey = await importHmacKey(secret);
  }
  return cachedKey;
}

export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    if (request.method !== "POST") {
      return new Response("Method Not Allowed", { status: 405 });
    }

    const key = await getKey(env.WEBHOOK_SECRET);

    let bodyBytes: Uint8Array;
    try {
      bodyBytes = await verifyWebhookSignature(request, key);
    } catch (err) {
      return new Response(`Unauthorized: ${(err as Error).message}`, {
        status: 401,
      });
    }

    const payload = JSON.parse(new TextDecoder().decode(bodyBytes));

    switch (payload.type) {
      case "payment.succeeded":
        await handlePaymentSucceeded(payload);
        break;
      case "subscription.cancelled":
        await handleSubscriptionCancelled(payload);
        break;
      default:
        // Unknown event type: acknowledge and move on
        break;
    }

    return new Response(null, { status: 200 });
  },
};

The cachedKey variable persists for the lifetime of the isolate. In Cloudflare's model, a single isolate instance handles many requests before being recycled. Caching the CryptoKey means you pay the importKey cost only on the first request to that isolate instance, not on every subsequent request.

For Vercel Edge Functions, use process.env.WEBHOOK_SECRET instead of env.WEBHOOK_SECRET. For Deno Deploy, use Deno.env.get("WEBHOOK_SECRET"). The verification logic is identical across all three.


Platform Differences at a Glance

FeatureCloudflare WorkersVercel EdgeDeno Deploy
crypto.subtleFull WinterCGFull WinterCGFull WinterCG
TextEncoder / TextDecoder
Buffer✗, use Uint8Array✗, use Uint8Array✓ (Node compat layer)
Env variable accessenv param in fetch()process.envDeno.env.get()
Module-scope state persistenceYes, per isolateYes, within cold startYes, per isolate
Key caching benefitHighMedium (cold starts reset)High

Deno Deploy's Node.js compatibility layer makes Buffer available, but relying on it breaks portability. If you use Uint8Array throughout, the same verification code runs on Workers, Vercel Edge, and Deno Deploy without modification.


Common Pitfalls

Missing await on crypto calls. Every Web Crypto function is async. Forgetting await before importKey, sign, or verify returns a Promise object rather than the result. TypeScript will usually catch this with noImplicitAny, but only if your return types are fully annotated.

Calling request.json() before reading the raw body. Once you call request.json() or request.text(), the underlying ReadableStream is consumed. You can't then call request.arrayBuffer() to get the bytes for HMAC computation. Always read arrayBuffer() first.

Comparing signatures with ===. A === comparison of two hex strings leaks timing information about where the strings first differ. This is a meaningful attack surface for signature verification. The Web Crypto spec mandates that subtle.verify() is constant-time — use it.

Not setting a body size limit. Edge runtimes don't automatically cap request body sizes. A malicious sender can POST a large body and exhaust your worker's CPU budget. Check request.headers.get("content-length") before calling arrayBuffer(), or add a streaming size check as you read.


Wrapping Up

Webhook signature verification in edge runtimes uses the same cryptographic primitives as Node.js, but the API surface is different in ways that trip people up: everything is async, Buffer is gone, and key material must be imported before use. Once you internalize those three differences, the Web Crypto API is clean and actually simpler in some ways — subtle.verify() handles constant-time comparison for you, which is one less correctness concern to manage.

The verification patterns above are portable across Cloudflare Workers, Vercel Edge Functions, and Deno Deploy. If you need to go further — replay protection backed by persistent storage, fan-out routing, or dead-letter queuing for failed events — GetHook provides the delivery infrastructure so your edge function only has to acknowledge and process.

Get started with GetHook →

Stop losing webhook events.

GetHook gives you reliable delivery, automatic retry, and full observability — in minutes.