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:
| Capability | Node.js crypto | Web Crypto (crypto.subtle) |
|---|---|---|
| HMAC computation | createHmac(alg, key).update(data).digest() | subtle.sign("HMAC", key, data) (async) |
| Key handling | Pass raw Buffer or string | Must call subtle.importKey() first |
| Timing-safe compare | crypto.timingSafeEqual(a, b) | Use subtle.verify() directly |
| Binary buffers | Buffer | Uint8Array / ArrayBuffer |
| Execution model | Synchronous available | Fully 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:
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:
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:
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
| Feature | Cloudflare Workers | Vercel Edge | Deno Deploy |
|---|---|---|---|
crypto.subtle | Full WinterCG | Full WinterCG | Full WinterCG |
TextEncoder / TextDecoder | ✓ | ✓ | ✓ |
Buffer | ✗, use Uint8Array | ✗, use Uint8Array | ✓ (Node compat layer) |
| Env variable access | env param in fetch() | process.env | Deno.env.get() |
| Module-scope state persistence | Yes, per isolate | Yes, within cold start | Yes, per isolate |
| Key caching benefit | High | Medium (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.