Verifying signatures
Always verify the HMAC-SHA256 signature before processing any payload. Without verification you risk acting on forged requests.
How to verify
- Read the raw, unparsed request body as bytes.
- Compute
sha256={hex}where{hex}is the HMAC-SHA256 hex digest of(secret, raw_body). - Compare your computed value to the
X-Webhook-Signatureheader using a constant-time comparison.
Warning — Use the raw bytes
Use the raw bytes, not re-serialized JSON. Re-encoding the body (e.g.
JSON.parsethenJSON.stringify) will change byte ordering and whitespace, breaking verification.
Node.js (Express)
const crypto = require('crypto');
function verifyWebhookSignature(rawBody, signatureHeader, secret) {
const expected = `sha256=${crypto
.createHmac('sha256', secret)
.update(rawBody)
.digest('hex')}`;
// During secret rotation the header may contain two comma-separated signatures.
const signatures = signatureHeader.split(',').map(s => s.trim());
return signatures.some(sig => {
if (sig.length !== expected.length) return false;
return crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected));
});
}
// Use express.raw() so you receive the unparsed body.
app.post(
'/webhooks/jeeves',
express.raw({ type: 'application/json' }),
(req, res) => {
const valid = verifyWebhookSignature(
req.body,
req.headers['x-webhook-signature'],
process.env.JEEVES_WEBHOOK_SECRET
);
if (!valid) return res.status(401).send('Invalid signature');
const event = JSON.parse(req.body);
// Enqueue for background processing — do NOT block here.
res.status(200).send('OK');
}
);Python (FastAPI / Flask)
import hmac, hashlib
def verify_webhook_signature(raw_body: bytes, signature_header: str, secret: str) -> bool:
expected = 'sha256=' + hmac.new(
secret.encode('utf-8'),
raw_body,
hashlib.sha256,
).hexdigest()
# During secret rotation, header may contain two comma-separated signatures.
signatures = [s.strip() for s in signature_header.split(',')]
return any(hmac.compare_digest(sig, expected) for sig in signatures)Go
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"strings"
)
func VerifyWebhookSignature(rawBody []byte, signatureHeader, secret string) bool {
mac := hmac.New(sha256.New, []byte(secret))
mac.Write(rawBody)
expected := "sha256=" + hex.EncodeToString(mac.Sum(nil))
for _, sig := range strings.Split(signatureHeader, ",") {
sig = strings.TrimSpace(sig)
if hmac.Equal([]byte(sig), []byte(expected)) {
return true
}
}
return false
}Dual signatures during secret rotation
While a rotation is in progress (24-hour grace period), X-Webhook-Signature contains two signatures separated by a comma:
X-Webhook-Signature: sha256=<new_secret_sig>, sha256=<old_secret_sig>The verifiers above already accept the request if either signature matches.