DEV Community

gokul
gokul

Posted on

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.

 And everyone hits the same trap: **you must verify against the raw request
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode
 ```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),
Enter fullscreen mode Exit fullscreen mode

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

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

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'
Enter fullscreen mode Exit fullscreen mode

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")
     }
Enter fullscreen mode Exit fullscreen mode

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/)

Top comments (0)