All articles

Security · July 25, 2026 · 11 min read

Webhook security: HMAC signatures, replay attacks and best practices

A webhook endpoint is, by definition, a public URL that accepts POST requests from anywhere on the internet. Left unprotected, anyone who discovers (or guesses) that address can forge a "payment approved" event and make your system ship product for free. The good news: the defense is well understood — HMAC signatures, replay protection and a handful of practices that providers like Stripe and GitHub have applied for years. This guide explains each layer and shows the code.

Why your endpoint is an attack surface

Unlike the rest of your API, a webhook endpoint doesn't sit behind a login: the provider must reach it with no session, no cookie, no OAuth. It is a door that is always open. And what comes through it usually triggers valuable actions — marking an invoice as paid, granting access, kicking off fulfillment.

The most obvious attack requires no sophistication at all. If your system trusts any JSON that arrives with "type": "payment.approved", one well-crafted curl is enough to defraud it:

a forged POST is trivial
curl -X POST https://your-app.com/webhooks/payments \
  -H "Content-Type: application/json" \
  -d '{"type":"payment.approved","data":{"payment_id":"pay_fake","amount":14990}}'

The question webhook security answers is: how do you know the request really came from the provider and wasn't altered along the way? The industry-standard answer is the HMAC signature.

HMAC signatures: how they work

HMAC (Hash-based Message Authentication Code) combines a hash function — almost always SHA-256 — with a shared secret known only to the provider and you. The flow:

  1. When you register the webhook, the provider generates (or you define) a secret, e.g. whsec_a1b2c3…. It is stored on both sides and never travels with the deliveries.
  2. Before sending each event, the provider computes HMAC-SHA256(secret, body) over the exact bytes of the payload and ships the result in a header — something like X-Hub-Signature-256 or Stripe-Signature.
  3. On receipt, you redo the same computation with your copy of the secret and compare. If it matches, the message is authentic and intact; if it doesn't, discard it.

Since only someone who knows the secret can produce a valid signature, an attacker may know your URL and the payload format — without the secret, no forged POST gets through. And because the hash covers the entire body, any byte altered in transit also invalidates the signature.

Verifying in Node.js

server.js · HMAC signature verification
import crypto from "node:crypto";
import express from "express";

const app = express();

// The body MUST arrive raw: use express.raw, not express.json,
// on this route — the signature was computed over the original bytes.
app.post(
  "/webhooks/payments",
  express.raw({ type: "application/json" }),
  (req, res) => {
    const secret = process.env.WEBHOOK_SECRET;
    const received = req.get("x-signature-sha256") ?? "";

    const expected = crypto
      .createHmac("sha256", secret)
      .update(req.body) // Buffer with the raw body
      .digest("hex");

    const a = Buffer.from(received);
    const b = Buffer.from(expected);
    if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
      return res.status(401).end(); // no details about the reason
    }

    const event = JSON.parse(req.body.toString("utf8"));
    // enqueue the processing and respond fast
    res.status(200).end();
  },
);

Two details in this code break more integrations than anything else:

  • Use the raw body. If a middleware parses the JSON and you recompute the HMAC over JSON.stringify(req.body), verification will fail intermittently: re-serialization can change key order, unicode escapes and whitespace — different bytes, different hash. The signature is always verified over the bytes exactly as they arrived.
  • Compare with timingSafeEqual. A regular comparison (===) returns faster the earlier the strings diverge, and that response time leaks information that lets an attacker reconstruct the signature byte by byte — the so-called timing attack. Constant-time comparison closes that channel.

The headers where each provider places this signature — and the other headers that accompany a delivery — are dissected in Anatomy of a webhook request.

Replay attacks: the signature alone is not enough

Suppose an attacker manages to capture one complete, legitimate delivery — headers and body — through an exposed log, a misconfigured proxy or a forgotten inspection endpoint. They don't know the secret, but they don't need to: the signature for that specific message is already there. All they have to do is resend the same request, untouched, as many times as they want. That's a replay attack — and pure HMAC verification accepts every copy.

The standard defense is to include a timestamp in the signed material and reject old messages. That's exactly how the Stripe webhook is designed: the Stripe-Signature header carries t= (timestamp) and v1= (signature), and the HMAC is computed over {timestamp}.{body}. Because the timestamp is part of the hash, the attacker can't "refresh" an old message without breaking the signature.

verification with a tolerance window (Stripe-style)
const TOLERANCE_SECONDS = 5 * 60; // 5 minutes

function verifySignedWebhook(header, rawBody, secret) {
  // header: "t=1784990400,v1=5257a869e7ecebeda32affa62cdca3fa..."
  const parts = Object.fromEntries(
    header.split(",").map((kv) => kv.split("=")),
  );

  const ageSeconds = Math.abs(Date.now() / 1000 - Number(parts.t));
  if (ageSeconds > TOLERANCE_SECONDS) return false; // stale message: replay?

  const expected = crypto
    .createHmac("sha256", secret)
    .update(`${parts.t}.${rawBody}`)
    .digest("hex");

  const a = Buffer.from(parts.v1 ?? "");
  const b = Buffer.from(expected);
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

The tolerance window (5 minutes is Stripe's default) exists to accommodate slightly skewed clocks and network delays. Inside the window, idempotency completes the defense: if you record the id of every processed event and ignore repeats, a replay inside the window becomes a harmless no-op.

How the major providers sign

The mechanism is the same everywhere; what changes is the header, the encoding and whether a timestamp is included:

ProviderHeaderFormatTimestamp?
StripeStripe-Signaturet=…,v1=… — hex HMAC-SHA256 over t.bodyYes, in the signature
GitHubX-Hub-Signature-256sha256=… — hex HMAC-SHA256 of the bodyNo
ShopifyX-Shopify-Hmac-Sha256HMAC-SHA256 of the body, base64-encodedNo
Mercado Pagox-signaturets=…,v1=… — hex HMAC-SHA256 over a manifest with id and request-idYes, in the signature

Before implementing, read your provider's specification — the details (what goes into the signed material, hex vs. base64) vary, and any divergence makes verification fail. GitHub's documentation on validating webhook deliveries is a good example of a well-written reference, with test vectors to check your implementation against.

Complementary best practices

The signature is the foundation, but a serious security posture adds more layers:

  • HTTPS, always. Without TLS, the payload and signature travel in the clear — any intermediary can capture the pair and gain replay material. Serious providers won't even let you register an http:// URL.
  • One secret per endpoint, with rotation. A secret shared across environments (production, staging, dev) multiplies the leak points. Generate one per endpoint and rotate periodically — providers like Stripe support two active secrets during rotation, so you can switch without dropping deliveries.
  • IP allowlists — as an extra layer, never the only one. Some providers publish their source IP ranges. Filtering on them reduces noise, but IPs change without notice, lists go stale and network origin can be spoofed in compromised-infrastructure scenarios. Treat it as defense in depth, not a substitute for the signature.
  • Verification failed? 401 and silence. Respond 401 (or 400) with no body explaining why. Messages like "expected signature: X" are a debugging oracle for the attacker.
  • Don't log sensitive payloads. Payment webhooks carry emails, documents, amounts. Logs with full payloads become the leak that feeds replay and social engineering. Log metadata (event id, type, verification result) and mask the rest.
  • Confirm via the API before releasing value. Even with a valid signature, a robust flow treats the webhook as a notification and queries the provider's API to confirm the state before any irreversible action — granting access, settling an invoice, issuing a refund.

Worth saying: most real-world incidents don't come from elaborate attacks, but from shortcuts — verification disabled "just in staging", a secret committed to the repository, a test endpoint forgotten in production. These and other stumbles are covered in The 10 most common webhook implementation mistakes.

Security checklist

  • Endpoint serves HTTPS only.
  • HMAC signature verified over the raw body, on every request, in every environment.
  • Constant-time comparison (timingSafeEqual or equivalent).
  • Timestamp checked against a tolerance window (when the provider signs a timestamp).
  • Deduplication by event id (idempotency) covering replays inside the window.
  • One secret per endpoint, kept out of the code, with a rotation plan.
  • A 401 with no details when verification fails.
  • Logs free of sensitive payloads; critical actions confirmed via the API.

Summary

  • A webhook endpoint is a public URL: without verification, any forged POST becomes "payment approved".
  • HMAC-SHA256 with a shared secret authenticates origin and integrity — always over the raw body, always with constant-time comparison.
  • Replay is fought with a timestamp inside the signed material + a tolerance window + idempotency by event id.
  • HTTPS, per-endpoint secrets with rotation, detail-free responses and clean logs complete the layers; IP allowlists are extra, not the foundation.
  • An authenticated webhook is still a notification: confirm via the API before releasing value.