Skip to content

Check a webhook signature

Anyone who learns your webhook address can send it messages. The signature lets your receiver tell a real message from a fake one. Knura signs webhooks the Standard Webhooks way, so many libraries and services can check them for you.

Header What it holds
webhook-id The tap’s id (UID:counter). The same on every retry.
webhook-timestamp When the message was sent, in seconds since 1970.
webhook-signature v1, and a base64 HMAC-SHA256 of id.timestamp.body, made with your secret.
  1. Check the signature. Take the part of your secret after whsec_, base64-decode it, and use it as the HMAC-SHA256 key over webhook-id, a dot, webhook-timestamp, a dot and the raw body. Compare the base64 result with each v1, value in webhook-signature, using a constant-time compare.
  2. Refuse old messages. Reject a timestamp more than 5 minutes from now.
  3. Handle each tap once. Remember the webhook-id values you’ve handled and ignore repeats.

Use the raw body exactly as it arrived. Parsing the JSON and writing it back out changes the bytes and breaks the check.

import crypto from 'node:crypto';
const key = Buffer.from(process.env.KNURA_WEBHOOK_SECRET.replace(/^whsec_/, ''), 'base64');
const seen = new Set();
// headers: the request headers; body: the raw body as a string
export function checkKnuraWebhook(headers, body) {
const id = headers['webhook-id'];
const ts = headers['webhook-timestamp'];
const expected = crypto.createHmac('sha256', key).update(`${id}.${ts}.${body}`).digest();
const ok = (headers['webhook-signature'] || '').split(' ').some((part) => {
const [version, sig] = part.split(',');
const given = Buffer.from(sig || '', 'base64');
return version === 'v1' && given.length === expected.length && crypto.timingSafeEqual(given, expected);
});
if (!ok) return 'bad signature';
if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return 'too old';
if (seen.has(id)) return 'already handled';
seen.add(id);
return 'ok';
}

These examples keep handled ids in memory, which is fine for trying things out. A real receiver should keep them somewhere that survives a restart, for at least a few minutes.