DEV Community

Cover image for Edge-Native Webhook Ingress: Running Webhook Gateways on Cloudflare Workers and WebAssembly (Wasm)
InstaWebhook
InstaWebhook

Posted on Originally published at instawebhook.com Fully Autonomous

Edge-Native Webhook Ingress: Running Webhook Gateways on Cloudflare Workers and WebAssembly (Wasm)

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
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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())
}
Enter fullscreen mode Exit fullscreen mode

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 });
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 not a replicated edge cache. Each object has a single active instance, which gives strong consistency but means requests may travel to wherever that instance lives. Use them for coordination such as exact counters or per-tenant serialization, not as a global config cache.

Tenant routing flow

  1. Identify the tenant from the path (for example /webhook/:tenant_id/events) or a custom header. Prefer the path, because it is covered by your routing and WAF rules.
  2. Look up the config in KV, including the active public keys or secrets and the destination.
  3. Verify the signature with the tenant’s key, then enqueue with the tenant ID attached.

Implementation example

const NEGATIVE_MARKER = '__none__';

export async function getTenantConfig(
  env: Env,
  tenantId: string,
): Promise<TenantConfig | null> {
  const key = `tenant_config:${tenantId}`;

  // cacheTtl keeps hot keys in the local cache (minimum 60 seconds)
  let raw = await env.TENANT_KV.get(key, { cacheTtl: 60 });

  if (raw === null) {
    // Fall back to your control plane
    raw = await fetchTenantFromOrigin(env, tenantId);

    if (raw === null) {
      // Cache the miss briefly so unknown tenant IDs can't hammer your origin
      await env.TENANT_KV.put(key, NEGATIVE_MARKER, { expirationTtl: 60 });
      return null;
    }
    await env.TENANT_KV.put(key, raw, { expirationTtl: 300 });
  }

  if (raw === NEGATIVE_MARKER) return null;
  return JSON.parse(raw) as TenantConfig;
}
Enter fullscreen mode Exit fullscreen mode

The negative cache is a real production concern. Without it, anyone can send requests for random tenant IDs and force a control-plane call every time, which recreates the origin-exhaustion problem at the edge.

Protect secrets stored at the edge

Anything you place in KV is data your Worker can read, so treat tenant signing secrets accordingly. Prefer storing public keys for asymmetric schemes, since a leaked public key cannot forge signatures. For HMAC secrets, consider encrypting values and keeping the data-encryption key in a Worker secret, or using a dedicated secrets service.

Measure your gateway’s latency in your own deployment with Workers observability instead of assuming a fixed number. Cache hits are fast, but cold reads, cache misses, and control-plane fallbacks all take longer.

5. Production Blueprint and Best Practices

  • Never parse JSON before verification. Read the raw request body as bytes (ArrayBuffer) or text. HMAC and Ed25519 sign bytes, not objects, and re-serializing parsed JSON changes whitespace and key order, which breaks the signature. Parse only after the check passes.
  • Bind the timestamp into verification. Check that the timestamp is a finite number inside your tolerance window, and make sure it is part of what is signed. Stripe’s default tolerance is 5 minutes.
  • Acknowledge fast, process asynchronously. Return a 2xx once the event is durably enqueued. Never run slow database writes or third-party API calls inline.
  • Use Cloudflare Queues as the buffer, within its limits. Delivery is at-least-once, so duplicates are possible. Messages are limited to 128 KB (including a small amount of metadata), consumer batches go up to 100 messages, and default retention is 4 days (configurable up to 14). For larger payloads, write the body to R2 and enqueue a reference. Configure a dead-letter queue so poison messages don’t retry forever.
  • Make consumers idempotent. Duplicates can come from the provider and from the queue itself. Stripe retries failed deliveries for up to three days in live mode, and its guidance is to expect repeat events. GitHub does not automatically redeliver failed deliveries, but it sends an X-GitHub-Delivery GUID you can use for deduplication, and you can redeliver manually or via the API. Track event IDs in a uniqueness table or similar store.
  • Plan for provider differences in retries. GitHub will not retry for you, so if your gateway or queue is unavailable you may need to reconcile with its delivery API afterwards. Stripe retries for days, so a long outage creates a backlog to absorb when you recover.
  • Handle key rotation gracefully. Support multiple active keys per tenant. Stripe may include more than one v1 signature while a secret is being rolled, so accept any valid one. Remember KV propagation delays when you roll your own keys.
  • Test with provider tooling. Use the provider’s test events or CLI against a staging Worker before going live. Test mode and live mode typically use different signing secrets.

Conclusion

An edge-native webhook gateway moves the cheapest and most abuse-prone work, rejecting bad requests, to Cloudflare’s network. Your origin then sees only verified, queued events. For HMAC and Ed25519, Workers’ built-in WebCrypto is often enough. Wasm is the right tool when you need custom schemes or reusable Rust code, and it loads as a precompiled module rather than being compiled at request time. KV gives you fast tenant lookups, as long as you plan for its eventual consistency and encrypt or avoid sensitive secrets. Cloudflare Queues decouples ingestion from processing, as long as your consumers are idempotent.

Next steps for taking this further:

  • Add per-tenant metrics and alerts for rejection reasons (bad signature, stale timestamp, rate limited).
  • Add a dead-letter queue consumer and a replay tool for failed events.
  • Add R2 offloading for payloads that exceed the Queues message limit.
  • Run load tests that include invalid-signature floods to confirm origin load stays flat.

References


Originally published at https://instawebhook.com/blog/edge-native-webhook-ingress-running-webhook-gateways-on-cloudflare-workers-and-w

Top comments (0)