Mesrai
Back to blog
// essayTechnical Deep Dive

Webhook Signature Verification Skipped on a New Provider

Real PR catch: webhook handler without HMAC check — anyone can forge events. timingSafeEqual fix.

Mesrai TeamAugust 19, 20268 min read

A Razorpay webhook handler. No signature verification. Anyone who knows the URL can POST a fake `payment.captured` event and mark orders paid without paying. Mesrai's catch.

The vulnerable diff

tswebhooks/razorpay.ts
// webhooks/razorpay.ts
razorpayRouter.post("/webhook", async (req, res) => {
  // BUG: no signature check. Anyone can POST a payment.captured event.
  const event = req.body;
  if (event.event === "payment.captured") {
    await markOrderPaid(event.payload.payment.entity.order_id);
  }
  res.json({ ok: true });
});

What is wrong

Webhook providers sign requests with HMAC over the request body using a shared secret. The handler must verify the signature on every request — otherwise an attacker who knows the URL (which usually isn't a secret) can forge events. The provider documentation always includes the verification code; the developer has to remember to use it. CWE-345 covers the broader class of insufficient verification of data authenticity.

The attack

Attack:

textexploit
curl -X POST https://api.example.com/webhooks/razorpay \
  -d '{
    "event":"payment.captured",
    "payload":{"payment":{"entity":{"order_id":"order_attacker"}}}
  }'
# Mark any order as paid. No verification.

Same shape works for every webhook provider — Stripe, Shopify, GitHub, Twilio, etc.

Mesrai's review comment

textreview-comment.txt
mesraipilot · Bot · reviewed 1 min ago

[mesrai] [code-review] [Security] [CWE-345] [critical]

Webhook endpoint accepts any POST without verifying the signature.
Attacker can forge events and trigger arbitrary state changes.

Verify the HMAC signature on every request using the shared secret:

  import crypto from "crypto";

  razorpayRouter.post("/webhook",
    express.raw({ type: "application/json" }),  // raw body required
    async (req, res) => {
      const sig = req.header("x-razorpay-signature") ?? "";
      const expected = crypto
        .createHmac("sha256", process.env.RZP_WEBHOOK_SECRET!)
        .update(req.body)
        .digest("hex");

      if (
        sig.length !== expected.length ||
        !crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))
      ) {
        return res.status(401).end();
      }

      const event = JSON.parse(req.body.toString());
      // ... process event
      res.status(200).end();
    }
  );

Two details: use the RAW body (not parsed JSON) for HMAC, and use
`timingSafeEqual` to prevent timing-attack leaks.

Reference: Razorpay Webhook Signature Verification docs, CWE-345

The fix

tswebhooks/razorpay.ts (fixed)
// webhooks/razorpay.ts — fixed
import crypto from "crypto";
import express from "express";

razorpayRouter.post(
  "/webhook",
  express.raw({ type: "application/json" }),
  async (req, res) => {
    const sig = req.header("x-razorpay-signature") ?? "";
    const expected = crypto
      .createHmac("sha256", process.env.RZP_WEBHOOK_SECRET!)
      .update(req.body)
      .digest("hex");

    if (
      sig.length !== expected.length ||
      !crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))
    ) {
      return res.status(401).end();
    }

    const event = JSON.parse(req.body.toString());
    if (event.event === "payment.captured") {
      await markOrderPaid(event.payload.payment.entity.order_id);
    }
    res.status(200).end();
  }
);

Raw body for HMAC. Timing-safe equality. Reject 401 on mismatch. Compose with the idempotency check from cluster 3 post 30 — both are required for production-grade webhook handling.

Why human review missed it

Webhook signature checks are mandatory but often forgotten. The pattern is provider-specific (header name, hash algorithm, body shape) and the documentation lives in the provider's site, not the framework. Mesrai catches every webhook handler that doesn't verify a signature.

Related rules + further reading

Mesrai rule pack: security/webhook-signature-required — flags webhook endpoints without HMAC verification.

Razorpay / Stripe / Shopify / GitHub docs: all document signature verification.

CWE-345 — Insufficient Verification of Data Authenticity.

Takeaway

Verify HMAC on every webhook. Raw body. Timing-safe compare. Reject 401 on mismatch. Mesrai catches the gap.

// try

See it on your next PR.

Free for individuals. Install in two minutes. Mesrai reviews every commit.