Skip to content

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.

If you write the check yourself, it takes four steps:

  1. Take the secret after whsec_ and decode it from base64. That is the key.
  2. 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.
  3. Base64-encode the result. The request is Sealcord’s if any v1, entry of webhook-signature equals it. Compare in constant time.
  4. Refuse a webhook-timestamp more than 5 minutes from your clock: it may be a replay.
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.

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
})
}

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.

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.