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
- Raw body first. Any middleware that parses the body before verification breaks the signature.
- Constant-time comparison. timingSafeEqual / compare_digest — never == on signatures.
- 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)