Webhook signature validation can never succeed: the global JSON body parser consumes the body before the webhook route
Exact reproduction steps (as recorded)
- Compute
hmac_sha256(rawBody, RAZORPAY_WEBHOOK_SECRET)exactly asvalidateWebhookSignature()does. - Send that exact raw body and the correct signature to
POST /api/webhooks/razorpay, once withfetch()and once withcurl --data-binaryusing a byte-identical file, to rule out client-side body changes. - Observe the response.
HTTP 200 with { success: true, message: 'Webhook received and validated' }.
HTTP 401 Webhook signature validation failed, every time, even with a provably correct signature over the provably correct bytes.
Root cause
app.use(express.json()) was mounted globally before the webhook router. It consumed
and parsed the request body, so the route's own express.raw() received an
already-parsed object instead of a Buffer. req.body.toString('utf-8') on a plain object
returns the literal string "[object Object]", and the HMAC was computed over that instead of the
real payload. The comparison was correct; it was hashing the wrong bytes. Fixed by mounting the webhook
router before the global parser:
app.use('/api/webhooks', webhooksRouter);
// Middleware
app.use(express.json());
The handler that depends on it, which hashes the raw body and only then parses it:
router.post('/razorpay', express.raw({ type: 'application/json' }), (req, res) => {
try {
const signature = req.headers['x-razorpay-signature'];
const eventId = req.headers['x-razorpay-event-id'];
if (!signature) {
console.error('❌ Webhook rejected: Missing X-Razorpay-Signature header');
return res.status(401).json({
error: 'Unauthorized',
message: 'X-Razorpay-Signature header is required',
});
}
if (!eventId) {
console.warn('⚠️ Webhook received without X-Razorpay-Event-Id header');
return res.status(400).json({
error: 'Bad Request',
message: 'X-Razorpay-Event-Id header is required',
});
}
// Check for duplicate webhook event before validation
if (isWebhookEventProcessed(eventId)) {
console.log(`ℹ️ Webhook event already processed (idempotent): ${eventId}`);
return res.status(200).json({
success: true,
eventId,
message: 'Webhook already processed',
});
}
// Validate signature using raw body
const rawBody = req.body.toString('utf-8');
const isValid = validateWebhookSignature(rawBody, signature);
Impact
No correctly signed webhook could be accepted. It failed safe, not open: a forged webhook could not get through either, so this was a functional defect and not a security hole. It also went unnoticed because the tests that only assert rejection (a 401 was already the expected answer) never exercised the accept path.
Regression tests
New: WEB-FIX-001 (valid signature over the exact raw body is accepted and moves the order from created to paid),
WEB-FIX-002 (modified payload with the original signature is rejected and the order is untouched),
WEB-FIX-003 (invalid signature rejected), WEB-FIX-004 (duplicate event ID handled idempotently).
Previously failing, now passing: WEB-001, WEB-005, WEB-006, WEB-007.
All are in tests/security/webhook-validation.spec.js (opens in a new tab)
and run in CI.
Reproduced again on 15 Oct 2026
To check the report rather than repeat it, I cloned the repository at da9e7a2, installed it, and ran the
webhook spec with the CI placeholder configuration. With the fix in place, all 11 tests passed. With the fix reverted in a
scratch copy (webhook router mounted after express.json(), nothing else changed), 6 of the 11 failed:
WEB-001, WEB-005, WEB-006, WEB-007, WEB-FIX-001 and WEB-FIX-004. The five rejection-only tests
(WEB-002, WEB-003, WEB-004, WEB-FIX-002, WEB-FIX-003) still passed, which is why the defect hid for so long.