Verify webhook signatures
Every webhook delivery carries an X-Webhook-Signature header: the hex-encoded HMAC-SHA256 of the raw request body using the webhook’s signing secret. Verify it before you trust anything else. An attacker who can reach your endpoint can forge a body; they cannot forge a valid signature without the secret.
The contract
X-Webhook-Signature = hex(HMAC_SHA256(secret, raw_body)) — available since v1.0.
- Secret is the
whsec_live_...value returned when the webhook was created (see Receive webhooks). - Signature is 64 lowercase hex characters.
- Compare with a timing-safe equality function. Never use
==or===— those leak the correct signature one byte at a time.
Node.js
// Express 4.x, Node 20+, @novavms/sdk optionalimport express from 'express';import crypto from 'node:crypto';
const SECRET = process.env.NOVAVMS_WEBHOOK_SECRET!; // 'whsec_live_7c4a1d9e...'
const app = express();
// Capture the raw body — do NOT use express.json() before this route.app.post('/webhooks/novavms', express.raw({ type: 'application/json' }), (req, res) => { const signature = req.header('X-Webhook-Signature') ?? ''; const timestamp = req.header('X-Webhook-Timestamp') ?? '';
// Reject replays older than 5 minutes. const age = Date.now() - Date.parse(timestamp); if (!Number.isFinite(age) || age > 5 * 60 * 1000) { return res.status(401).end('stale'); }
const expected = crypto.createHmac('sha256', SECRET).update(req.body).digest('hex'); const ok = signature.length === expected.length && crypto.timingSafeEqual(Buffer.from(signature, 'hex'), Buffer.from(expected, 'hex'));
if (!ok) return res.status(401).end('bad signature');
const payload = JSON.parse(req.body.toString('utf8')); // ... enqueue payload.data for async processing ... res.status(204).end();});Python
# FastAPI, Python 3.10+, hmac + hashlib from stdlibimport hmacimport hashlibimport osfrom datetime import datetime, timezonefrom fastapi import FastAPI, Request, HTTPException
SECRET = os.environ["NOVAVMS_WEBHOOK_SECRET"].encode() # b'whsec_live_7c4a1d9e...'app = FastAPI()
@app.post("/webhooks/novavms")async def receive(request: Request): raw = await request.body() signature = request.headers.get("X-Webhook-Signature", "") timestamp = request.headers.get("X-Webhook-Timestamp", "")
# Reject replays older than 5 minutes. try: sent_at = datetime.fromisoformat(timestamp.replace("Z", "+00:00")) except ValueError: raise HTTPException(401, "bad timestamp") if (datetime.now(timezone.utc) - sent_at).total_seconds() > 300: raise HTTPException(401, "stale")
expected = hmac.new(SECRET, raw, hashlib.sha256).hexdigest() if not hmac.compare_digest(signature, expected): raise HTTPException(401, "bad signature")
# Safe to parse now. return {"ok": True}curl (manual test)
You can sign a synthetic body locally to confirm your verifier is correct:
BODY='{"webhook_id":"a9f3c1e2-0000-4000-8000-000000000001","event_type":"alert"}'SECRET='whsec_live_7c4a1d9e8b2f3a5c6d9e0f1a2b3c4d5e'SIG=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$SECRET" -hex | awk '{print $2}')
curl -X POST http://localhost:3000/webhooks/novavms \ -H "X-Webhook-Signature: $SIG" \ -H "X-Webhook-Timestamp: $(date -u +%Y-%m-%dT%H:%M:%SZ)" \ -H "Content-Type: application/json" \ -d "$BODY"Expected response: 204 No Content. Flip one byte in $BODY before signing and confirm you get 401.
Secret rotation
Rotating the signing secret invalidates the old one the moment you save the rotation — see Create and rotate webhooks for the rolling-cutover pattern that avoids downtime. On the receiver side, support two secrets during the migration window: verify against the new secret first, fall back to the old one, log which matched. Once all deliveries match the new secret, delete the old one from your config.
function verify(body: Buffer, signature: string, secrets: string[]): boolean { for (const secret of secrets) { const expected = crypto.createHmac('sha256', secret).update(body).digest('hex'); if ( signature.length === expected.length && crypto.timingSafeEqual(Buffer.from(signature, 'hex'), Buffer.from(expected, 'hex')) ) return true; } return false;}Related
- Receive webhooks — register a URL and read the delivery headers
- Webhook event types — schema per event type
- Webhook delivery failures — diagnosing 401s in the delivery history