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.

4 min readSume
All posts

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

Sume webhook checks and the failure each one stops, from the webhook guide (read 2026-10-03)
CheckStopsResponse
Secret is non-emptyAn empty key that makes every forged HMAC validRefuse to start
Timestamp is digits within 300 sReplay of an old captured delivery401
Header entry length equals expectedA RangeError from timingSafeEqual401
Any sume-v1 entry matchesA forged body or wrong secret401 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}/status as 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

All Developers posts

Written by Sume