timingSafeEqual throws on unequal length: Sume webhook check (Node)
Different-length buffers make Node's timingSafeEqual throw, so a bad Sume signature can become a 500. Check length first and a node:http receiver returns 401.

Verifying a Sume webhook by hand in Node looks easy: compute the HMAC, compare it to the header, done. The comparison is where a first version breaks. crypto.timingSafeEqual(a, b) throws a RangeError when the two buffers have different byte lengths. An attacker, or just a misconfigured proxy, who sends a short or garbage signature header turns your verifier into an unhandled exception, and your server answers 500 instead of 401.
Sume's contract is documented: the signature is sume-v1= followed by the hex HMAC-SHA256 of <timestamp>.<raw_body>, sent in x-sume-webhook-signature with the timestamp in x-sume-webhook-timestamp. During a secret rotation the header can carry several comma-separated entries, and any matching entry is valid.
A receiver with the length check
The verifier below reads the raw bytes before parsing JSON, because re-serialising a parsed body changes the bytes and breaks the signature. It rejects a missing secret at startup, a non-numeric timestamp and anything outside 300 seconds, then compares each header entry only when its length matches.
import { createHmac, timingSafeEqual } from "node:crypto";
import { createServer } from "node:http";
const SECRET = process.env.SUME_WEBHOOK_SECRET ?? "";
if (!SECRET) throw new Error("SUME_WEBHOOK_SECRET is required");
export function verify(raw, headers, now = Date.now() / 1000) {
const ts = headers["x-sume-webhook-timestamp"];
const header = headers["x-sume-webhook-signature"];
if (typeof ts !== "string" || typeof header !== "string") return false;
if (!/^\d+$/.test(ts) || Math.abs(now - Number(ts)) > 300) return false;
const digest = createHmac("sha256", SECRET).update(`${ts}.`).update(raw).digest("hex");
const want = Buffer.from(`sume-v1=${digest}`);
return header.split(",").some((part) => {
const got = Buffer.from(part.trim());
return got.length === want.length && timingSafeEqual(got, want);
});
}
const server = createServer(async (req, res) => {
const chunks = [];
for await (const c of req) chunks.push(c);
const raw = Buffer.concat(chunks);
if (!verify(raw, req.headers)) return res.writeHead(401).end();
console.log("verified", JSON.parse(raw.toString()).event);
res.writeHead(200).end();
});A self-test
Append this to the same file. It starts the server on a free port, sends one correctly signed request and one with a short bad signature, then closes. Set SUME_WEBHOOK_SECRET to any non-empty test value and run it as an ES module. You should see verified job.completed, then 200 401.
await new Promise((r) => server.listen(0, r));
const url = `http://127.0.0.1:${server.address().port}`;
const raw = JSON.stringify({ event: "job.completed" });
const ts = String(Math.floor(Date.now() / 1000));
const sig = "sume-v1=" + createHmac("sha256", SECRET).update(`${ts}.${raw}`).digest("hex");
const post = (signature) => fetch(url, { method: "POST", body: raw, headers: {
"x-sume-webhook-timestamp": ts, "x-sume-webhook-signature": signature } });
console.log((await post(sig)).status, (await post("sume-v1=00")).status);
server.close();What each check protects against
| Check | Stops | Response |
|---|---|---|
| Secret is non-empty | An empty key that makes every forged HMAC valid | Refuse to start |
| Timestamp is digits within 300 s | Replay of an old captured delivery | 401 |
| Header entry length equals expected | A RangeError from timingSafeEqual | 401 |
| Any sume-v1 entry matches | A forged body or wrong secret | 401 on none |
Receiver rules
- Use the raw body for the HMAC. Parse JSON only after the signature passes.
- Compare lengths first. Return 401 for a mismatch, never let the exception reach the framework.
- Return a 2xx quickly after verifying and do the real work, such as downloading the artifact, from a queue.
- A rejected delivery is not lost. Sume can redeliver, and a redelivery carries a fresh timestamp and signature, so fix the receiver and ask for another.
- Keep polling
GET /v1/jobs/{id}/statusas a backup for jobs whose webhook you missed.
The header names, rotation rule and event payloads are in the webhook guide. Node documents the throwing behaviour on its crypto page.
Sources
Related posts
More in Developers
- Node-RED http request: node headers overwrite msg.headers (Sume key)
Node-RED's http request node lets node-configured headers overwrite msg.headers. Keep Sume's one credential header in the node, per-call headers in the msg.
- Node-RED http request node: submit a Sume job and branch on statusCode
Node-RED's http request node returns payload, statusCode and headers. Wire a Sume submit, branch on 202 vs errors, and keep the job id for the status read.
- ^0.2.0 does not accept 0.3.0: pinning @sume-com/sdk in npm
A caret range on a 0.x version only allows patch updates: ^0.2.0 means >=0.2.0 <0.3.0-0. What that means for @sume-com/sdk upgrades and CI.
- oasdiff breaking: catch Sume OpenAPI changes in CI
Commit a snapshot of the Sume OpenAPI spec and run oasdiff breaking against the live document in CI, so changes that break your client show up before release.
Written by Sume