Why JSON.stringify(req.body) breaks Stripe & Shopify webhook HMAC-SHA256 verification
When a Stripe 2025-03-31.basil subscription webhook, a Shopify Plus B2B orders/updated webhook (with financial_status: "partially_paid"), or a signed revclamp.entitlement.revoke circuit-breaker webhook reaches your backend, the HMAC-SHA256 digest in the HTTP header was computed over the exact raw UTF-8 request bytes transmitted over the wire.
Yet the single most common webhook verification bug in Node.js, Next.js App Router, and Express middleware is letting a body-parser deserialize the request first and then calling JSON.stringify(req.body) before computing the HMAC digest. Even a single stripped whitespace byte, reordered object key, or normalized Unicode escape (\u0026 vs &) changes the SHA-256 digest completely and causes intermittent 400 Bad Signature drops in production.
1. Why parsed JSON objects fail cryptographic signature checks
When Express (express.json()) or a Next.js route handler parses an incoming webhook body into a JavaScript object and re-serializes it via JSON.stringify(req.body), three byte-level mutations occur:
- Float & scientific-notation normalization: A raw JSON number such as
2500.00in a payment payload is parsed into IEEE-7542500and re-serialized as"2500", dropping the trailing.00bytes. - Unicode escape & slash canonicalization: Providers that escape forward slashes (
\/) or non-ASCII characters (\u00fc) in raw webhook bodies produce different UTF-8 bytes after V8 parses and re-stringifies them. - Integer-indexed key reordering: V8 sorts integer-like object keys in ascending numeric order during object enumeration, silently mutating key order on nested metadata maps.
2. Provider-specific canonical signing differences: Stripe vs. Shopify vs. RevClamp
Even when you capture the exact raw UTF-8 buffer via await request.text(), each B2B commerce and billing provider uses a distinct signing envelope and digest encoding:
| Failure Mode / Provider Rule | Header & Canonical Input | Naive Implementation Bug | Deterministic Verification Guard |
|---|---|---|---|
| 1. Parsed JSON Re-Serialization | Raw UTF-8 request body buffer | Computes HMAC over JSON.stringify(req.body) |
Read raw bytes first (await req.text()) before JSON.parse |
| 2. Stripe Timestamped Hex Envelope | Stripe-Signature: t=<unix>,v1=<hex> over {t}.{rawBody} |
Hashes rawBody without prepending {t}. or ignores multiple v1= keys |
Construct {timestampSec}.${rawPayload} and verify lower-case hex digest |
| 3. Shopify Base64 Digest Encoding | X-Shopify-Hmac-Sha256: <base64> over raw UTF-8 body |
Compares .digest("hex") against Shopify's Base64 header string |
Compute .digest("base64") over raw UTF-8 bytes with constant-time compare |
| 4. Stale Webhook Replay Attack | t=<unix_seconds> in Stripe-Signature / X-RevClamp-Signature |
Verifies HMAC match but omits timestamp skew tolerance check | Reject with SIGNATURE_TIMESTAMP_OUTSIDE_TOLERANCE when |nowSec - t| > 300s |
| 5. Timing Side-Channel & Secret Leak | Signature comparison & browser diagnostic tools | Uses === string comparison or pastes live whsec_ / shpss_ keys into web tools |
Use crypto.timingSafeEqual and enforce LIVE_SECRET_FORBIDDEN_IN_SANDBOX |
3. Testing webhook signatures via MCP (revclamp_verify_webhook_signature) or Browser Sandbox
Both our interactive browser utility (/tools/webhook-signature-tester) and our 6th Model Context Protocol tool (revclamp_verify_webhook_signature on POST https://api.revclamp.com/api/mcp) run the exact same deterministic verifier with a Zero-Trust guard that blocks live credentials (sk_live_, rk_live_, non-test whsec_..., shpss_..., shpat_..., rg_live_, rc_live_, AKIA..., and PEM private keys):
{
"jsonrpc": "2.0",
"id": "webhook-hmac-check-1",
"method": "tools/call",
"params": {
"name": "revclamp_verify_webhook_signature",
"arguments": {
"provider": "stripe",
"secret": "whsec_test_secret_revclamp_sandbox_2026",
"rawPayload": "{\"id\":\"evt_basil_sandbox_01\",\"api_version\":\"2025-03-31.basil\",\"type\":\"customer.subscription.updated\"}"
}
}
}