Back to guides

Secure Webhook Verification

By Davy Rogers

Webhooks are HTTP callbacks that push data to your server. Without verification, anyone can send fake events to your endpoint. Here's how to verify webhooks correctly and handle the edge cases.

Verify the Signature

Most webhook providers sign the payload with HMAC-SHA256. The signature is sent in a header. Your server must recompute the signature and compare.

Stripe Example

Stripe sends the signature in the Stripe-Signature header:

import Stripe from "stripe";

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
const endpointSecret = process.env.STRIPE_WEBHOOK_SECRET!;

export async function POST(request: Request) {
  const body = await request.text(); // raw body, not parsed JSON
  const sig = request.headers.get("stripe-signature")!;

  let event: Stripe.Event;
  try {
    event = stripe.webhooks.constructEvent(body, sig, endpointSecret);
  } catch (err) {
    return new Response("Invalid signature", { status: 400 });
  }

  // Process event...
  return new Response("OK", { status: 200 });
}

Reference: Stripe - Check Webhook Signatures

GitHub Example

GitHub sends the signature in X-Hub-Signature-256:

import crypto from "crypto";

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

  return crypto.timingSafeEqual(
    Buffer.from(signature),
    Buffer.from(expected)
  );
}

Reference: GitHub - Validating Webhook Deliveries

Generic HMAC Verification

For any provider that uses HMAC-SHA256:

import crypto from "crypto";

function verifyHmacSignature(
  payload: string,
  receivedSignature: string,
  secret: string
): boolean {
  const computed = crypto
    .createHmac("sha256", secret)
    .update(payload, "utf8")
    .digest("hex");

  // Timing-safe comparison to prevent timing attacks
  return crypto.timingSafeEqual(
    Buffer.from(receivedSignature),
    Buffer.from(computed)
  );
}

Use the Raw Body

Signature verification requires the exact bytes the provider signed. If your framework parses the body (JSON.parse), the re-serialized version may differ.

In Next.js App Router:

const body = await request.text(); // raw string

In Express:

app.post("/webhook", express.raw({ type: "application/json" }), (req, res) => {
  const body = req.body; // Buffer
  // verify signature using body
});

Do not use express.json() for webhook endpoints.

Use Timing-Safe Comparison

Always use crypto.timingSafeEqual() to compare signatures. Regular string comparison (===) leaks information about which bytes match through timing differences.

// BAD - vulnerable to timing attacks
if (computedSig === receivedSig) { ... }

// GOOD
crypto.timingSafeEqual(Buffer.from(computedSig), Buffer.from(receivedSig));

Reference: Node.js - crypto.timingSafeEqual

Prevent Replay Attacks

An attacker who captures a valid webhook can replay it later. Defenses:

Timestamp Validation

Most providers include a timestamp in the request. Reject requests older than a threshold (e.g., 5 minutes):

const timestamp = parseInt(request.headers.get("x-webhook-timestamp")!);
const now = Math.floor(Date.now() / 1000);

if (Math.abs(now - timestamp) > 300) {
  return new Response("Request too old", { status: 400 });
}

Stripe includes the timestamp in the Stripe-Signature header (the t= parameter). Their SDK validates this automatically.

Event ID Deduplication

Store processed event IDs and reject duplicates:

const eventId = event.id;

// Check if already processed
const exists = await db.webhookEvent.findUnique({ where: { eventId } });
if (exists) {
  return new Response("Already processed", { status: 200 });
}

// Process and record
await db.webhookEvent.create({ data: { eventId, processedAt: new Date() } });

Clean up old records periodically (TTL matching your replay window).

Implement Idempotency

Webhook providers retry on failure. Your handler must produce the same result whether called once or five times:

  • Use the event ID as an idempotency key.
  • Make database operations idempotent (upserts, conditional updates).
  • Return 200 after processing, even on retries.
// Idempotent: upsert based on external event ID
await db.order.upsert({
  where: { stripePaymentId: event.data.object.id },
  create: { ... },
  update: { ... },
});

Return the Right Status Code

StatusMeaningProvider Action
200-299SuccessNo retry
400Bad request (invalid signature)No retry (most providers)
500Server errorRetry later
TimeoutNo responseRetry later

Return 200 quickly. If you need to do slow processing, queue the event and process asynchronously.

Secure the Endpoint

  • IP allowlisting: if the provider publishes their IP ranges, restrict access: Stripe - Webhook IP Addresses
  • HTTPS only: never accept webhooks over HTTP.
  • No authentication bypass: do not skip signature verification for "testing."

Checklist

  • Signature verified using HMAC with timing-safe comparison
  • Raw body used (not re-serialized JSON)
  • Timestamp validated (reject old requests)
  • Event IDs deduplicated
  • Handler is idempotent
  • Returns 200 quickly (async processing for slow tasks)
  • HTTPS only
  • Webhook secret stored securely (not in code)

Related Guides

Published 04 Mar 2026

Frequently asked questions

How should webhook signatures be verified?
HMAC over the raw request body using a shared secret, compared with a constant-time function. Read the body before any framework parses it - re-serialised JSON breaks the signature.
Whats the most common webhook bug?
Verifying a signature against the parsed JSON instead of the raw bytes. Different JSON renderers reorder keys and re-encode strings, so the recomputed HMAC will not match.
Do I need replay protection on webhooks?
Yes. Require a timestamp in the signed payload, reject requests outside a small window (5 minutes is typical), and store recent IDs to drop duplicates.
Should webhook handlers be idempotent?
Always. Networks retry, providers retry, and any replay protection you add reinforces the assumption. Use the providers event ID as your idempotency key.

Related

Want a professional to look at it?Get an AppSec Health Check.