Node.js cookbook · Intermediate

Verify webhook signatures in Express

Accept signed webhooks safely: verify the raw body, prevent replays and drop duplicates.

Approach

Mount express.raw({ type: "application/json" }) on the webhook route only (the HMAC must be computed over the exact raw bytes), verify with crypto.timingSafeEqual, check timestamp tolerance, and insert the event ID with a unique constraint before acknowledging.

Solution

import crypto from "node:crypto";
import express from "express";

const SECRET = process.env.WEBHOOK_SECRET!;

function verify(raw: Buffer, header: string | undefined) {
  const parts = Object.fromEntries((header ?? "").split(",").map((p) => p.split("=", 2)));
  const ts = Number(parts.t);
  const expected = crypto.createHmac("sha256", SECRET).update(`${ts}.`).update(raw).digest();
  const given = Buffer.from(parts.v1 ?? "", "hex");
  const fresh = Math.abs(Date.now() / 1000 - ts) <= 300;
  return fresh && given.length === expected.length && crypto.timingSafeEqual(given, expected);
}

app.post("/webhooks/payments", express.raw({ type: "application/json", limit: "1mb" }), async (req, res) => {
  if (!verify(req.body as Buffer, req.get("x-signature"))) return res.status(400).send("Invalid signature");
  const event = JSON.parse((req.body as Buffer).toString("utf8"));
  const { rowCount } = await pool.query(
    "INSERT INTO webhook_events (event_id, type, payload) VALUES ($1, $2, $3) ON CONFLICT (event_id) DO NOTHING",
    [event.id, event.type, event],
  );
  if (rowCount) await webhookQueue.add("process", { eventId: event.id });
  res.status(200).json({ received: true });
});

Pitfalls

  • Global express.json() consuming the body before the webhook route.
  • timingSafeEqual throws on different lengths — check length first.
  • Processing synchronously and timing out.

Variations

  • Provider SDKs (e.g., stripe.webhooks.constructEvent).

Further reading: Node.js: crypto.timingSafeEqual

Reference: https://nodejs.org/api/crypto.html#cryptotimingsafeequala-b

More Node.js recipes