Modern distributed systems lean on webhooks to drive event-driven workflows, sync data between SaaS platforms, trigger deployments, and process billing events. The traditional design routes webhook traffic from providers such as Stripe, GitHub, or Shopify straight into a centralized Kubernetes cluster or cloud region. That design has real security and capacity drawbacks.

A public webhook endpoint accepts requests from anyone, so it is only as safe as its signature verification. If every request, valid or not, has to travel to your origin to be rejected, bursts and floods consume ingress, API gateway, and worker capacity. That can end in connection starvation, resource exhaustion, and cascading failures.

Moving signature verification, payload checks, and tenant routing to the edge with Cloudflare Workers and, where useful, WebAssembly (Wasm) changes this. Cloudflare reports a network of more than 330 cities, and your gateway code runs inside it. Bad requests are dropped before they reach your origin, and valid events are acknowledged quickly and handed off asynchronously.

This article walks through the architecture, the code, and the limits you need to design around. Where a claim depends on platform limits or provider behavior, it follows the current documentation. Links are at the end.

1. The Architectural Shift: Moving Ingress to the Edge

In a legacy webhook architecture, an HTTP POST hits your load balancer and passes through an API gateway. It then consumes application memory to parse a body, runs signature verification in your application runtime, and finally writes state to a database.

[Legacy Flow] Webhook Provider ──> Cloud LB ──> API Gateway ──> App Cluster (CPU/memory spent on invalid requests) [Edge-Native Flow] Webhook Provider ──> Cloudflare Worker (limits, verify, drop) ──> Queue ──> Origin consumers 

The legacy model has several weaknesses:

  • Wasted compute on invalid requests. Misconfigured clients, scanners, and attackers can send garbage or replayed payloads. Your origin still spends CPU reading the body and running signature checks before it can reject them.
  • Shared failure domains. A webhook flood competes with your user-facing APIs for the same pods, connection pools, and database connections.
  • Tight coupling to provider timeouts. Providers expect a fast response. GitHub treats a delivery as failed if your server does not return a 2xx within 10 seconds, and Stripe’s guidance is to return a 2xx quickly, before running slow logic. If your origin is slow or down, retries pile on top of the load you already have.

An edge gateway acts as a stateless front door. It checks size, content type, rate, and signature, then writes accepted events to a durable queue and returns a 2xx.

One caveat: geographic latency is a weaker argument than it first appears. Webhook senders are usually cloud services calling from a handful of regions, not end users spread around the world. The main benefits are shielding your origin and acknowledging deliveries fast, not shaving round-trip time for every sender.

2. Choosing the Right Verification Primitive: WebCrypto First, Wasm When Needed

Know what your providers actually use

Signature schemes differ by provider, so check the documentation of each sender you support:

Provider Scheme Notes
Stripe HMAC-SHA256 over timestamp.payload, in the Stripe-Signature header (t=…,v1=…) Default timestamp tolerance is 5 minutes. The header can carry multiple v1 signatures during secret rotation.
GitHub HMAC-SHA256 over the raw body, in X-Hub-Signature-256 (sha256=<hex>) Payloads are capped at 25 MB. GitHub does not automatically redeliver failed deliveries.
Discord Ed25519, via X-Signature-Ed25519 and X-Signature-Timestamp Asymmetric. You verify with Discord’s public key.
Telnyx (Webhook API v2) Ed25519, via telnyx-signature-ed25519 and telnyx-timestamp Signs the timestamp and raw body together.
Standard Webhooks HMAC (v1) or Ed25519 (v1a), with webhook-id, webhook-timestamp, and webhook-signature headers An open specification with an asymmetric variant.

Two things follow. Most big providers use HMAC-SHA256, not Ed25519. And in every scheme above, the timestamp is part of the signed data or checked alongside it. That matters for the replay protection discussed below.

Workers’ WebCrypto already covers the common cases

The Workers runtime implements the Web Crypto API, and Cloudflare notes that WebCrypto operations are significantly faster than doing the same work in pure JavaScript. HMAC is supported, and so is Ed25519 (as the standard Ed25519 algorithm, plus a legacy NODE-ED25519 variant). For many gateways, crypto.subtle.verify is therefore the right first choice. It needs no extra build step and no extra bundle size.

Here is a Stripe-style HMAC verifier using only WebCrypto:

const enc = new TextEncoder(); function hexToBytes(hex: string): Uint8Array | null { if (hex.length % 2 !== 0 || !/^[0-9a-fA-F]*$/.test(hex)) return null; const out = new Uint8Array(hex.length / 2); for (let i = 0; i < out.length; i++) { out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16); } return out; } function concat(a: Uint8Array, b: Uint8Array): Uint8Array { const out = new Uint8Array(a.length + b.length); out.set(a, 0); out.set(b, a.length); return out; } export async function verifyStripeStyle( rawBody: Uint8Array, header: string, secret: string, toleranceSec = 300, ): Promise<boolean> { const pairs = header.split(',').map((p) => p.split('=') as [string, string]); const t = pairs.find(([k]) => k === 't')?.[1]; const sigs = pairs.filter(([k]) => k === 'v1').map(([, v]) => v); if (!t || sigs.length === 0) return false; const ts = Number(t); if (!Number.isFinite(ts) || Math.abs(Date.now() / 1000 - ts) > toleranceSec) { return false; } const key = await crypto.subtle.importKey( 'raw', enc.encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['verify'], ); const signedPayload = concat(enc.encode(`${t}.`), rawBody); for (const sig of sigs) { const sigBytes = hexToBytes(sig); // crypto.subtle.verify performs the comparison for you if (sigBytes && (await crypto.subtle.verify('HMAC', key, sigBytes, signedPayload))) { return true; } } return false; } 

Note the Number.isFinite guard. Without it, a non-numeric timestamp becomes NaN, and Math.abs(NaN) > 300 is false, so the stale-timestamp check would silently pass.

Where Wasm fits

Wasm earns its place when you need something WebCrypto does not offer. Examples are a provider-specific or non-standard scheme, a Rust library you already trust and want to reuse, or heavier parsing and canonicalization logic. Compiling Rust to Wasm gives you near-native execution speed and a sandboxed linear memory, and you can reuse audited code.

Some claims about Wasm need correcting. The sandbox isolates a module from the host, but it does not prevent leaks or guarantee predictable performance. That comes from how you write the module. Rust’s ownership model helps, but Wasm does not make memory management magically safe. Treat any speed claim as something to benchmark in your own Worker.

CPU limits are also not a blocker. Workers on the Free plan allow 10 ms of CPU time per request, while Paid plans default to 30 seconds and can be raised to 5 minutes. Time spent waiting on I/O does not count as CPU time.

An Ed25519 verification module in Rust

This module uses the ed25519-dalek 2.x API. It uses verify_strict, which also rejects some malleable or weak-key edge cases that plain verify accepts. For webhooks, strict is the safer default.

use ed25519_dalek::{Signature, VerifyingKey}; use wasm_bindgen::prelude::*; #[wasm_bindgen] pub fn verify_webhook_signature( public_key_bytes: &[u8], message: &[u8], signature_bytes: &[u8], ) -> Result<bool, JsValue> { let key_array: &[u8; 32] = public_key_bytes .try_into() .map_err(|_| JsValue::from_str("Invalid public key length"))?; let verifying_key = VerifyingKey::from_bytes(key_array) .map_err(|_| JsValue::from_str("Malformed public key"))?; let signature = Signature::from_slice(signature_bytes) .map_err(|_| JsValue::from_str("Invalid signature format"))?; Ok(verifying_key.verify_strict(message, &signature).is_ok()) } 

Build it with the web target, for example wasm-pack build --target web.

Loading the module in a Worker

Cloudflare’s runtime does not let you compile Wasm from bytes at request time. Instead, Wrangler bundles any file ending in .wasm and your code imports it as a precompiled WebAssembly.Module. Cloudflare’s guidance is to instantiate it once at the top level, not on every request. With wasm-bindgen’s web target, that means calling initSync with the imported module:

import { initSync, verify_webhook_signature } from './pkg/webhook_crypto.js'; import wasmModule from './pkg/webhook_crypto_bg.wasm'; // Instantiate once per isolate, at module scope. initSync({ module: wasmModule }); 

Recent wasm-bindgen versions take an object ({ module }). Older versions took the module directly, so check the signature in your generated .js file.

The full gateway handler

The handler below puts the layers in order and verifies a generic Ed25519 scheme. The signed message is the timestamp plus the raw body, as in Telnyx’s scheme (which joins them with |) and Stripe’s (.). Adapt the signed-message format to your provider.

Binding the timestamp into the signature is what makes replay protection real. If you only check a timestamp header that is not covered by the signature, an attacker can replay an old valid body with a fresh header.

import { initSync, verify_webhook_signature } from './pkg/webhook_crypto.js'; import wasmModule from './pkg/webhook_crypto_bg.wasm'; initSync({ module: wasmModule }); export interface Env { TENANT_KV: KVNamespace; WEBHOOK_QUEUE: Queue; WEBHOOK_LIMITER: RateLimit; // Workers Rate Limiting binding } interface TenantConfig { publicKeysHex: string[]; // multiple entries during key rotation } // Cloudflare Queues messages are limited to 128 KB, so keep the accepted size well below that. const MAX_BODY_BYTES = 64 * 1024; const MAX_SKEW_SECONDS = 300; const enc = new TextEncoder(); const dec = new TextDecoder(); export default { async fetch(request: Request, env: Env): Promise<Response> { if (request.method !== 'POST') { return new Response('Method Not Allowed', { status: 405 }); } // 1. Route: /webhook/:tenantId/events const match = new URL(request.url).pathname.match(/^\/webhook\/([\w-]{1,64})\/events$/); if (!match) return new Response('Not Found', { status: 404 }); const tenantId = match[1]; // 2. Cheap per-location throttle (permissive and eventually consistent; see section 3) const ip = request.headers.get('cf-connecting-ip') ?? 'unknown'; const { success } = await env.WEBHOOK_LIMITER.limit({ key: `${tenantId}:${ip}` }); if (!success) return new Response('Too Many Requests', { status: 429 }); // 3. Size and content-type guards before touching the body const declared = Number(request.headers.get('content-length') ?? '0'); if (declared > MAX_BODY_BYTES) { return new Response('Payload Too Large', { status: 413 }); } if (!request.headers.get('content-type')?.startsWith('application/json')) { return new Response('Unsupported Media Type', { status: 415 }); } // 4. Tenant config (cached at the edge) const config = await getTenantConfig(env, tenantId); if (!config) return new Response('Unauthorized', { status: 401 }); // 5. Required headers and timestamp window const signatureHex = request.headers.get('X-Webhook-Signature'); const timestamp = request.headers.get('X-Webhook-Timestamp'); if (!signatureHex || !timestamp) { return new Response('Missing security headers', { status: 400 }); } const ts = Number(timestamp); if (!Number.isFinite(ts) || Math.abs(Date.now() / 1000 - ts) > MAX_SKEW_SECONDS) { return new Response('Stale or invalid timestamp', { status: 401 }); } // 6. Raw bytes. Never parse JSON before verification. const rawBody = new Uint8Array(await request.arrayBuffer()); if (rawBody.byteLength > MAX_BODY_BYTES) { return new Response('Payload Too Large', { status: 413 }); } // 7. Verify against every active key (supports rotation) const signature = hexToBytes(signatureHex); if (!signature) return new Response('Invalid Signature', { status: 401 }); const message = concat(enc.encode(`${timestamp}.`), rawBody); const valid = config.publicKeysHex.some((keyHex) => { const key = hexToBytes(keyHex); return key !== null && verify_webhook_signature(key, message, signature); }); if (!valid) return new Response('Invalid Signature', { status: 401 }); // 8. Durably enqueue BEFORE acknowledging await env.WEBHOOK_QUEUE.send({ tenantId, eventId: request.headers.get('X-Webhook-Id') ?? crypto.randomUUID(), receivedAt: Date.now(), body: dec.decode(rawBody), // JSON is UTF-8 text }); return new Response('Accepted', { status: 202 }); }, }; function hexToBytes(hex: string): Uint8Array | null { if (hex.length % 2 !== 0 || !/^[0-9a-fA-F]*$/.test(hex)) return null; const out = new Uint8Array(hex.length / 2); for (let i = 0; i < out.length; i++) { out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16); } return out; } function concat(a: Uint8Array, b: Uint8Array): Uint8Array { const out = new Uint8Array(a.length + b.length); out.set(a, 0); out.set(b, a.length); return out; } 

Because the Worker awaits send() before returning, a 202 means the event is in the queue. Do not use waitUntil for this step, or you could acknowledge events you later fail to enqueue.

3. CDN Boundary Protection: Layered Early Rejection

Application resilience depends on early rejection. The cheapest request to handle is one that never invokes your code, and the next cheapest is one rejected before the body is read.

A sensible order of defenses looks like this:

[Edge Security Pipeline] 1. Cloudflare network-level protections and WAF / rate limiting rules (before the Worker runs) 2. Optional provider IP allowlisting (defense in depth) 3. Worker: method, path, content-type, size guards 4. Worker: per-key rate limiting 5. Worker: tenant lookup, timestamp window, signature verification 6. Valid -> enqueue, return 2xx Invalid -> 4xx at the edge, zero origin compute 

Protective measures in practice

  • Platform and WAF rules first. Cloudflare processes traffic across its network and mitigates DDoS attacks there. A rejected request still invokes your Worker, so for the cheapest possible drop, put WAF custom rules or rate limiting rules in front of it rather than relying only on Worker code.
  • IP allowlisting as defense in depth. Some providers publish the IP ranges they send from. GitHub documents an allowlist approach and Stripe publishes its addresses. Signatures remain the real control, because IP ranges change and allowlists drift.
  • Size enforcement. Check Content-Length early, but also check the actual byte length after reading, because the header can be absent or wrong. Request body limits depend on your Cloudflare plan, and Cloudflare returns a 413 for requests that exceed them. Pick your own, much lower limit based on what your providers send. GitHub, for example, caps payloads at 25 MB, but most events are tiny.
  • Content-type validation. Accept only the types each provider documents.
  • Rate limiting with realistic expectations. The Workers Rate Limiting binding takes a limit and a period of either 10 or 60 seconds. Counters are tracked per key per Cloudflare location, and Cloudflare describes the API as permissive and eventually consistent, not an accurate accounting system. It is a good throttle against abuse, not a billing-grade quota. If you need exact, globally consistent counts, use a Durable Object, which provides strongly consistent state at the cost of routing those requests to a single instance.
  • Do not use KV for counters. KV is eventually consistent, and concurrent writes to the same key can overwrite each other. Writes to a single key are also rate limited.

4. Real-Time Tenant Routing with Edge KV

In a multi-tenant SaaS product, one gateway endpoint receives events for many customers, each with its own signing keys and routing rules. Querying a central database on every request adds latency and creates a new origin dependency.

Workers KV suits this read-heavy, rarely-changing data. It is a globally distributed, eventually consistent key-value store.

Know KV’s consistency model

KV writes are visible immediately in the location where they were made. In other locations they can take up to 60 seconds, or the cacheTtl you set on reads, to appear. Two other limits apply:

  • The minimum expirationTtl on writes is 60 seconds.
  • The minimum cacheTtl on reads is 60 seconds, which is also the default.

For webhooks this has a practical consequence. Key and secret changes are not instantaneous. After a rotation, some locations will keep serving the old config for a while, so your gateway must accept both old and new keys during an overlap window.

Durable Objects are ...