JustAppSec
Back to guides

Secure Webhook Verification

Webhooks are HTTP callbacks that push data to your server. Without verification, anyone can send fake events to your endpoint. This guide covers how to verify webhooks correctly and handle 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


Content is AI-assisted and reviewed by our team, but issues may be missed and best practices evolve rapidly, send corrections to [email protected]. Always consult official documentation and validate key implementation decisions before making design or security choices.

Need help?Get in touch.