Webhook Security: HMAC Signatures & Replay Attacks
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