Verify a webhook signature
Check every request’s signature before you act on it. Sealcord signs webhooks the
Standard Webhooks way, so its libraries verify them. A
request that fails the check should get a 4xx answer other than 410 and nothing more.
What a library does
Section titled “What a library does”If you write the check yourself, it takes four steps:
- Take the secret after
whsec_and decode it from base64. That is the key. - Compute HMAC-SHA256 with that key over
<webhook-id>.<webhook-timestamp>.<body>. Use the two headers as they came and the raw body, byte for byte as it arrived. Parsing the body and serialising it again changes the bytes. - Base64-encode the result. The request is Sealcord’s if any
v1,entry ofwebhook-signatureequals it. Compare in constant time. - Refuse a
webhook-timestampmore than 5 minutes from your clock: it may be a replay.
Node, with the standardwebhooks package
Section titled “Node, with the standardwebhooks package”import { createServer } from 'node:http';import { Webhook } from 'standardwebhooks';
// The endpoint's signing secret, whsec_…, from your environment.const webhook = new Webhook(process.env.SEALCORD_WEBHOOK_SECRET);
createServer(async (req, res) => { // Verify the raw body, exactly as it arrived: never a re-serialised object. const chunks = []; for await (const chunk of req) chunks.push(chunk); const body = Buffer.concat(chunks).toString('utf8');
let event; try { // Checks webhook-signature against the secret, and that // webhook-timestamp is within 5 minutes of now. event = webhook.verify(body, req.headers); } catch { res.writeHead(400).end(); return; }
// Delivery is at least once: handle each webhook-id once. const id = req.headers['webhook-id']; if (await alreadyHandled(id)) { res.writeHead(204).end(); return; } await queue(id, event); // { type, timestamp, data } res.writeHead(204).end();}).listen(8080);alreadyHandled and queue are yours: a table of handled ids, and the work done after you
answer. With Express or another framework, give verify the raw body, not the parsed one. In
Express, put express.raw({ type: 'application/json' }) on that route.
Node, with node:crypto only
Section titled “Node, with node:crypto only”import { createHmac, timingSafeEqual } from 'node:crypto';
export function verify( secret: string, headers: Record<string, string | undefined>, body: string, now = Date.now() / 1000,): boolean { const id = headers['webhook-id']; const timestamp = headers['webhook-timestamp']; const signatures = headers['webhook-signature']; if (id === undefined || timestamp === undefined || signatures === undefined) return false;
// Refuse a timestamp more than 5 minutes from now: a replay. const sent = Number(timestamp); if (Number.isInteger(sent) === false || Math.abs(now - sent) > 300) return false;
// The key is the secret after "whsec_", decoded from base64. const key = Buffer.from(secret.replace(/^whsec_/, ''), 'base64'); const expected = createHmac('sha256', key).update(`${id}.${timestamp}.${body}`).digest();
// One "v1,<base64>" entry per signing secret, separated by spaces. return signatures.split(' ').some((entry) => { const [version, value] = entry.split(','); if (version === 'v1' && typeof value === 'string') { const given = Buffer.from(value, 'base64'); return given.length === expected.length && timingSafeEqual(given, expected); } return false; });}Call it with the three headers and the raw body, before you parse the JSON. This sample is written for requests signed the Standard Webhooks way, as the API signs them: a request signed with the current secret passes, and so does one carrying two signatures, the old secret’s and the new one’s. A changed body, another secret and a timestamp more than 5 minutes old fail.
With the hmac (0.12), sha2 (0.10) and base64 (0.22) crates:
use base64::{Engine, engine::general_purpose::STANDARD};use hmac::{Hmac, Mac};use sha2::Sha256;
/// Whether a delivery is Sealcord's: `secret` is the endpoint's `whsec_…`/// secret, `id`, `timestamp` and `signatures` its `webhook-id`,/// `webhook-timestamp` and `webhook-signature` headers, `body` the raw body,/// and `now` the current Unix time in seconds.pub fn verify(secret: &str, id: &str, timestamp: &str, signatures: &str, body: &[u8], now: u64) -> bool { let Some(key) = secret.strip_prefix("whsec_").and_then(|s| STANDARD.decode(s).ok()) else { return false; }; // Refuse a timestamp more than 5 minutes from now: a replayed delivery. match timestamp.parse::<u64>() { Ok(sent) if sent.abs_diff(now) <= 300 => {} _ => return false, } // One `v1,<base64>` per signing secret, separated by spaces: two for 24 // hours after a rotation. Accept the delivery when any one matches. signatures .split(' ') .filter_map(|s| s.strip_prefix("v1,")) .filter_map(|s| STANDARD.decode(s).ok()) .any(|signature| { let mut mac = Hmac::<Sha256>::new_from_slice(&key).expect("HMAC takes any key length"); mac.update(id.as_bytes()); mac.update(b"."); mac.update(timestamp.as_bytes()); mac.update(b"."); mac.update(body); mac.verify_slice(&signature).is_ok() // constant-time comparison })}Questions
Section titled “Questions”Why must I verify the raw body?
Section titled “Why must I verify the raw body?”The signature covers the exact bytes Sealcord sent. A framework that parses the JSON and serialises it again can change spacing or key order, and the check then fails.
Why are there two signatures in the header?
Section titled “Why are there two signatures in the header?”For 24 hours after you replace the signing secret, every request carries a signature for the new secret and one for the old, so your receiver accepts it with either while you deploy the new one.
What status should I answer when the signature is wrong?
Section titled “What status should I answer when the signature is wrong?”Any 4xx but 410, and do nothing with the request. Sealcord retries a non-2xx answer, so a
genuine delivery that fails the check because of a deploy mistake is sent again. A 410 is
different: it fails the delivery and turns the endpoint off.
Why does the timestamp matter?
Section titled “Why does the timestamp matter?”Refusing a webhook-timestamp more than 5 minutes from your clock stops someone replaying an old
request they captured. Each attempt, a retry included, is signed afresh, so a genuine retry
passes.