A webhook endpoint is a public URL, and anyone on the internet can POST to it. So the rule is simple and non-negotiable: verify the raw request body before you parse it, compare the signature in constant time, and reject any timestamp older than about five minutes. Skip any one of those three and your /webhooks route becomes an anonymous, authenticated write into your own database. This guide gives you correct, copy-pasteable code for Stripe, GitHub, and Svix, and flags the four subtle bugs that make "verified" webhooks quietly forgeable.

Why you verify at all#

Your payment provider does not have a private tunnel to your server. It makes an ordinary HTTPS request to a URL that is, by design, reachable by the whole world. Without verification, an attacker who guesses or scrapes your endpoint can send {"type": "checkout.session.completed", "amount": 999900} and — if you trust it — get a product for free, or flip an account to "paid," or trigger a refund.

Verification proves two things at once: the request came from someone who holds the shared secret (authenticity), and the body wasn't altered in transit (integrity). Every major provider does this with HMAC-SHA256 — a keyed hash of the payload. You recompute the hash with the same secret and check that it matches what the sender put in a header.

The #1 bug: read the RAW body, not the parsed one#

HMAC is computed over exact bytes. If you let a framework parse the JSON and then re-serialize it to verify, you are hashing different bytes than the sender did. JSON.parse and JSON.stringify are not round-trip identical: key ordering, whitespace, and unicode escaping (é vs. é) can all change. The signature will never match, and worse, the "fix" people reach for — loosening the check — reopens the hole.

Verify the bytes on the wire, not your reconstruction of them. Once JSON has been parsed and re-stringified, the signature is checking a document that no longer exists.

In Express this means: do not mount express.json() in front of your webhook route. Capture the raw Buffer instead, scoped to that route only.

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

const app = express();

// Raw body for THIS route only. No express.json() ahead of it.
app.post(
  "/webhooks/stripe",
  express.raw({ type: "application/json" }),
  (req, res) => {
    const header = req.header("Stripe-Signature") ?? "";
    const rawBody = req.body as Buffer; // a Buffer, thanks to express.raw()

    let event: unknown;
    try {
      event = verifyStripe(rawBody, header, process.env.STRIPE_WEBHOOK_SECRET!);
    } catch {
      return res.status(400).send("Invalid signature");
    }

    // Only now is it safe to parse and act on the event.
    // Respond 2xx fast; do slow work on a queue.
    res.json({ received: true });
  }
);

HMAC construction and the replay window#

Stripe's Stripe-Signature header looks like t=1700000000,v1=5257a8.... The t is the Unix timestamp (seconds) when Stripe signed the event; v1 is the HMAC-SHA256 signature. Critically, the signed payload is not just the body — it is timestamp + "." + rawBody. That is what folds the timestamp into the signature so an attacker can't replay an old, validly-signed request with a fresh clock.

You enforce the replay window yourself: reject anything outside the tolerance. Stripe's official libraries default to a five-minute tolerance, and their docs are explicit that a tolerance of 0 is dangerous because it disables the recency check entirely.

const TOLERANCE_SECONDS = 5 * 60;

function verifyStripe(rawBody: Buffer, header: string, secret: string): unknown {
  // Parse "t=...,v1=...,v1=..."
  const fields = new Map(
    header.split(",").map((kv) => {
      const [k, v] = kv.split("=", 2);
      return [k, v] as const;
    })
  );
  const timestamp = Number(fields.get("t"));
  const signature = fields.get("v1");
  if (!timestamp || !signature) throw new Error("Malformed signature header");

  // 1. Replay window: reject stale timestamps.
  if (Math.abs(Date.now() / 1000 - timestamp) > TOLERANCE_SECONDS) {
    throw new Error("Timestamp outside tolerance");
  }

  // 2. Recompute HMAC over `${t}.${body}` using the RAW bytes.
  const signedPayload = Buffer.concat([
    Buffer.from(`${timestamp}.`, "utf8"),
    rawBody,
  ]);
  const expected = crypto
    .createHmac("sha256", secret)
    .update(signedPayload)
    .digest("hex");

  // 3. Constant-time compare.
  if (!timingSafeEqualHex(expected, signature)) {
    throw new Error("Signature mismatch");
  }

  return JSON.parse(rawBody.toString("utf8"));
}

Timing-safe comparison: never use ==#

A normal string comparison returns false as soon as it hits the first differing byte. That tiny, measurable timing difference lets an attacker brute-force a valid signature one byte at a time. The fix is a constant-time compare that always inspects the whole input. Node ships crypto.timingSafeEqual; Python ships hmac.compare_digest. Both are what every provider — Stripe, GitHub, and Svix — recommends.

One sharp edge: crypto.timingSafeEqual throws if the two buffers differ in length. Guard for it, and return false rather than letting the throw leak length information.

function timingSafeEqualHex(a: string, b: string): boolean {
  const bufA = Buffer.from(a, "hex");
  const bufB = Buffer.from(b, "hex");
  if (bufA.length !== bufB.length) return false;
  return crypto.timingSafeEqual(bufA, bufB);
}

Verifying multiple providers#

The mechanics are the same HMAC-SHA256; only the header name, encoding, and what-you-sign differ.

GitHub sends X-Hub-Signature-256, a hex digest prefixed with sha256=, computed over the raw body alone. (The older X-Hub-Signature uses SHA-1 and exists only for legacy compatibility — ignore it.) GitHub has no built-in timestamp, so if you need replay protection, layer on the X-GitHub-Delivery ID and store recently-seen IDs.

function verifyGitHub(rawBody: Buffer, header: string, secret: string): boolean {
  const expected =
    "sha256=" + crypto.createHmac("sha256", secret).update(rawBody).digest("hex");
  const a = Buffer.from(expected);
  const b = Buffer.from(header);
  if (a.length !== b.length) return false;
  return crypto.timingSafeEqual(a, b);
}

Svix / Standard Webhooks sign id.timestamp.body, send a base64 signature in webhook-signature (a space-delimited list like v1,g0hM9...), and put the timestamp in webhook-timestamp. The secret is whsec_<base64>, and you HMAC using the base64-decoded bytes after the prefix — a step people miss. Compare against each v1, entry with a constant-time check, and enforce your own tolerance on webhook-timestamp.

Here is a compact Python/Flask variant that verifies a Stripe-style signature. Note request.get_data() returns the raw bytes before any JSON parsing, and hmac.compare_digest does the constant-time work.

import hashlib
import hmac
import time
from flask import Flask, request, abort

app = Flask(__name__)
TOLERANCE_SECONDS = 5 * 60

@app.post("/webhooks/stripe")
def stripe_webhook():
    raw = request.get_data()  # raw bytes, BEFORE JSON parsing
    header = request.headers.get("Stripe-Signature", "")

    fields = dict(p.split("=", 1) for p in header.split(",") if "=" in p)
    timestamp, signature = fields.get("t"), fields.get("v1")
    if not timestamp or not signature:
        abort(400)

    if abs(time.time() - int(timestamp)) > TOLERANCE_SECONDS:
        abort(400)  # stale / replay

    signed = f"{timestamp}.".encode() + raw
    expected = hmac.new(
        app.config["STRIPE_WEBHOOK_SECRET"].encode(), signed, hashlib.sha256
    ).hexdigest()

    if not hmac.compare_digest(expected, signature):
        abort(400)

    event = request.get_json()  # safe to parse now
    return {"received": True}

Idempotency: passing the signature isn't the end#

A valid signature only proves authentic, not once. Providers retry aggressively — on any timeout, 5xx, or network blip — so the same correctly-signed event will legitimately arrive multiple times. If your handler creates a row or charges a card on each delivery, retries become duplicate orders.

Dedupe on the provider's event ID (event.id for Stripe, X-GitHub-Delivery for GitHub, webhook-id/svix-id for Svix). Record processed IDs in a table with a unique constraint, and make the insert your gate:

// Reject the second delivery of the same event at the DB layer.
try {
  await db.insert("processed_events", { id: event.id });
} catch (e) {
  if (isUniqueViolation(e)) return res.json({ received: true }); // already handled
  throw e;
}

Trust the signal, re-fetch the truth#

The last subtle bug: trusting the contents of the webhook body for high-value actions. Even a perfectly-verified event is only as fresh as the moment it was sent, and amounts, statuses, and entitlements can change. For anything that moves money or ships goods, treat the webhook as a nudge that says "something happened," then re-fetch the authoritative object from the provider's API (stripe.checkout.sessions.retrieve(id)) and act on that — the same discipline that keeps an agent's first Stripe payment honest. Verification stops forgery; a re-fetch stops you from acting on stale or partial data.

Get these five things right — raw body, HMAC over the exact signed payload, constant-time compare, a timestamp window, and idempotency — and your endpoint is boring in the best way. Boring is what secure looks like.