Back to Blog
Security 8 min read

Webhook Security: HMAC Signatures & Replay Attacks

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

Webhooks are how your form backend talks to your application. But most webhook implementations have a critical security gap: they don't verify who sent the request. Any attacker who discovers your webhook URL can POST fabricated data and your app will process it as legitimate.

The Attack Vector

Imagine a form submission triggers a webhook that creates a user account in your app. Without verification, an attacker can POST:

POST /webhooks/forge HTTP/1.1
Content-Type: application/json

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

If your handler doesn't validate the sender, you just created an attacker admin account. This is not hypothetical — it's happened to production systems.

HMAC-SHA256 Signature Verification

Forge signs every outbound webhook payload with HMAC-SHA256. The signature is included in the X-Forge-Signature request header. Your receiver must verify it before processing the payload.

import crypto from "crypto";

export function verifyForgeSignature(
  payload: string,
  signature: string,
  secret: string
): boolean {
  const expected = crypto
    .createHmac("sha256", secret)
    .update(payload, "utf8")
    .digest("hex");

  // Use timingSafeEqual to prevent timing attacks
  return crypto.timingSafeEqual(
    Buffer.from(signature, "hex"),
    Buffer.from(expected, "hex")
  );
}

// In your Next.js webhook handler:
export async function POST(req: Request) {
  const body = await req.text(); // Raw string, not parsed JSON
  const signature = req.headers.get("x-forge-signature") ?? "";

  if (!verifyForgeSignature(body, signature, process.env.FORGE_WEBHOOK_SECRET!)) {
    return new Response("Unauthorized", { status: 401 });
  }

  const data = JSON.parse(body);
  // Process safely...
}

Idempotency Keys and Replay Attack Prevention

Every Forge webhook includes an X-Forge-Event-ID header — a unique UUID for that specific event. Store processed event IDs in Redis with a 24-hour TTL. Before processing any webhook, check if you've already seen that event ID.

const eventId = req.headers.get("x-forge-event-id");
const alreadyProcessed = await redis.get(`webhook:${eventId}`);

if (alreadyProcessed) {
  return new Response("OK", { status: 200 }); // Acknowledge but don't double-process
}

await redis.set(`webhook:${eventId}`, "processed", { ex: 86400 });
// Process the event...

The Dead Letter Queue

If your endpoint returns a non-2xx response or times out, Forge retries with exponential backoff: immediately, then 1 min, 5 min, 30 min, 2 hours, 12 hours, 24 hours. After exhausting retries, the payload moves to your Dead Letter Queue (DLQ) in the dashboard.

The DLQ lets you inspect failed payloads and replay individual events after fixing your endpoint — zero data loss, even during a multi-day outage.

Read the Forge webhook documentation for the full security reference including IP allowlisting and audit log access. Start securing your data pipeline with a free Forge account.

Ready to build?

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

Start Free