Dev.to Security 🔐 Cybersecurity 👁 1 📖 2 min read

Webhook signature verification in Node, Python, and Rust — the raw-body trap

Every serious webhook provider — Stripe, GitHub, Shopify — signs deliveries with HMAC. Verifying that signature is your only proof an event came from the provider and not from someone who found your endpoint URL.

Every serious webhook provider — Stripe, GitHub, Shopify — signs deliveries
with HMAC. Verifying that signature is your only proof an event came from the
provider and not from someone who found your endpoint URL.

 And everyone hits the same trap: **you must verify against the raw request

body, before any parsing.** Re-serialized JSON is not byte-identical to what
was sent. One byte of difference — a space, key ordering — and every signature
check fails with errors that point everywhere except the real cause.

Here's the correct pattern in three languages.

 ## Node (Express)
 ```js
 const crypto = require('crypto');
 const express = require('express');
 const app = express();

 app.post('/webhooks/stripe',
   express.raw({ type: 'application/json' }), // raw body FIRST
   (req, res) => {
     const sig = req.headers['stripe-signature'];
     const expected = crypto
       .createHmac('sha256', process.env.WEBHOOK_SECRET)
       .update(req.body) // Buffer, not parsed JSON
       .digest('hex');

     const received = sig.split(',').find(p => p.startsWith('v1=')).slice(3);
     if (!crypto.timingSafeEqual(Buffer.from(expected),

Buffer.from(received))) {
return res.status(400).send('invalid signature');
}

     const event = JSON.parse(req.body); // parse AFTER verifying
     res.status(200).send('ok');
   }
 );

python
   Python (Flask)



   ```python
     import hashlib, hmac
     from flask import Flask, request, abort

     app = Flask(__name__)

     @app.post('/webhooks/github')
     def github_webhook():
         raw = request.get_data()  # raw bytes, before parsing
         expected = 'sha256=' + hmac.new(
             WEBHOOK_SECRET.encode(), raw, hashlib.sha256
         ).hexdigest()

         received = request.headers.get('X-Hub-Signature-256', '')
         if not hmac.compare_digest(expected, received):
             abort(400)

         event = request.get_json()  # parse AFTER verifying
         return 'ok'

Rust (axum)

     async fn webhook(headers: HeaderMap, body: Bytes) -> Result<&'static str,
   &'static str> {
         let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes())
             .map_err(|_| "bad key")?;
         mac.update(&body); // raw Bytes, not Value
         let expected = hex::encode(mac.finalize().into_bytes());

         let received = headers.get("x-hub-signature-256")
             .and_then(|v| v.to_str().ok())
             .and_then(|v| v.strip_prefix("sha256="))
             .ok_or("missing sig")?;

         if expected != received { return Err("invalid signature"); }
         // serde_json::from_slice(&body) AFTER this point
         Ok("ok")
     }

The three rules

  1. Raw body first. Any middleware that parses the body before verification breaks the signature.
  2. Constant-time comparison. timingSafeEqual / compare_digest — never == on signatures.
  3. Verify, then process, then 200. Acknowledging before the work commits is how events get lost on crashes.

Full walkthrough including idempotency (dedupe on event ID) and retry testing:
testing webhooks locally, the complete guide
(https://21tunnel.com/blog/test-stripe-webhooks-locally/)

📰 Read the original article on Dev.to Security

Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.