Back to Blog
Security 8 min read

Webhook Security: HMAC Signatures & Replay Attacks

S
Sarah ChenStaff Engineer, Forge · Published January 20, 2026

Webhooks are the backbone of modern form pipelines — they connect your form submissions to your CRM, Slack, provisioning system, and data warehouse. But most webhook implementations ship with a critical security gap: no verification that the sender is who they claim to be. This guide covers HMAC-SHA256 signature verification, replay attack prevention with idempotency keys, and the Dead Letter Queue pattern used by Forge to guarantee zero data loss.

1. The Attack: Unsigned Webhooks Are Trivially Exploitable

If your webhook handler doesn't verify who sent a request, any attacker who discovers your endpoint URL can POST fabricated form submission data. If that webhook triggers account creation or privilege changes, the consequences are severe:

# An attacker discovers your webhook endpoint and sends:
POST /webhooks/form-submissions HTTP/1.1
Content-Type: application/json

{
  "email": "attacker@evil.com",
  "role": "admin",
  "plan": "enterprise",
  "verified": true
}

# If your handler trusts this blindly — you just promoted an attacker.

This is not a theoretical vulnerability. Form webhook endpoints are publicly discoverable via your network tab and often have predictable patterns like /api/webhooks/form. Without signature verification, your endpoint is an open door.

2. HMAC-SHA256 Signature Verification

Forge signs every outbound webhook payload with HMAC-SHA256. The signature is sent in the X-Forge-Signature header. Your receiver computes the expected signature using the shared secret and compares it using constant-time comparison (to prevent timing attacks).

import crypto from "crypto";

export function verifyForgeSignature(
  rawBody: string,   // ← must be raw text, not parsed JSON
  signature: string,
  secret: string
): boolean {
  const expected = crypto
    .createHmac("sha256", secret)
    .update(rawBody, "utf8")
    .digest("hex");

  // timingSafeEqual prevents timing-based signature oracle attacks
  try {
    return crypto.timingSafeEqual(
      Buffer.from(signature, "hex"),
      Buffer.from(expected, "hex")
    );
  } catch {
    return false; // buffer length mismatch = invalid signature
  }
}

// Next.js App Router route handler:
export async function POST(req: Request) {
  const rawBody  = await req.text();            // ← text(), NOT json()
  const sig      = req.headers.get("x-forge-signature") ?? "";
  const verified = verifyForgeSignature(rawBody, sig, process.env.FORGE_WEBHOOK_SECRET!);

  if (!verified) {
    return new Response("Unauthorized", { status: 401 });
  }

  const payload = JSON.parse(rawBody);
  await processSubmission(payload);
  return new Response("OK", { status: 200 });
}
Critical: Always read the request body as raw text before verifying. If you callreq.json() first, the body stream is consumed and the signature will not match even on valid requests.

3. Replay Attack Prevention with Idempotency Keys

Signature verification proves the payload is authentic, but it doesn't prevent an attacker from capturing a valid signed request and replaying it hours later. Every Forge webhook includes an X-Forge-Event-ID — a unique UUID per event. Store processed IDs in Redis with a 24-hour TTL:

export async function POST(req: Request) {
  const rawBody  = await req.text();
  const eventId  = req.headers.get("x-forge-event-id") ?? "";
  const sig      = req.headers.get("x-forge-signature") ?? "";

  // 1. Verify authenticity
  if (!verifyForgeSignature(rawBody, sig, process.env.FORGE_WEBHOOK_SECRET!)) {
    return new Response("Unauthorized", { status: 401 });
  }

  // 2. Check for replay
  const seen = await redis.get(`webhook:processed:${eventId}`);
  if (seen) {
    return new Response("Already processed", { status: 200 }); // acknowledge, don't reprocess
  }

  // 3. Mark processed BEFORE doing work (prevents race conditions)
  await redis.set(`webhook:processed:${eventId}`, "1", { ex: 86400 });

  // 4. Process safely
  const payload = JSON.parse(rawBody);
  await processSubmission(payload);
  return new Response("OK", { status: 200 });
}

4. The Dead Letter Queue — Zero Data Loss

Even with perfect security, your endpoint will sometimes be unavailable — deployments, database migrations, flapping infrastructure. Forge retries failed webhook deliveries with exponential backoff:

  • Attempt 1: Immediate
  • Attempt 2: 1 minute later
  • Attempt 3: 5 minutes later
  • Attempt 4: 30 minutes later
  • Attempt 5: 2 hours later
  • Attempt 6: 12 hours later
  • Attempt 7: 24 hours later

After exhausting all retries, the payload moves to your Dead Letter Queue in the Forge dashboard. You can inspect the raw payload, see the failure reason, and replay individual events after fixing your endpoint — without losing a single submission.

Secure your form pipeline with Forge

HMAC-signed webhooks, exponential retry, and Dead Letter Queue — on every plan including the free tier.

Start building free

Ready to build?

Create your free Forge account. 4 forms, 100 submissions/month, forever free.

Start Free