import { createHmac, timingSafeEqual } from "node:crypto"; // Sender: HMAC-SHA256 over the exact bytes of the body, sent as "sha256=<hex>"export function sign(secret, rawBody) { return "sha256=" + createHmac("sha256", secret).update(rawBody).digest("hex");} // Receiver: recompute over the raw body and compare in constant timeexport function verify(secret, rawBody, header) { const match = /^sha256=([0-9a-f]{64})$/i.exec(header ?? ""); if (!match) return false; // a missing or malformed header fails, it never skips the check const expected = createHmac("sha256", secret).update(rawBody).digest(); return timingSafeEqual(Buffer.from(match[1], "hex"), expected); // 32 bytes each} import { createServer } from "node:http";import { verify } from "./webhook.mjs"; const secret = process.env.WEBHOOK_SECRET; createServer((req, res) => { const chunks = []; req.on("data", (chunk) => chunks.push(chunk)); req.on("end", () => { const rawBody = Buffer.concat(chunks); // the bytes as received, before any JSON parsing // Node.js lower-cases incoming header names if (!verify(secret, rawBody, req.headers["x-hub-signature-256"])) { res.writeHead(401).end("bad signature\n"); return; } const event = JSON.parse(rawBody); res.end(`accepted ${event.action} #${event.number}\n`); });}).listen(8080); How it works
createHmac("sha256", secret).update(rawBody).digest("hex")is the whole signature: 64 hex digits, prefixed withsha256=as GitHub, and many providers after it, send it.- The receiver recomputes the MAC over the same bytes and compares the two with
timingSafeEqual, whose running time does not depend on how many leading bytes match —===on strings returns at the first difference. - The regular expression accepts exactly
sha256=plus 64 hex digits, so a missing, truncated or garbled header fails before any comparison, andtimingSafeEqualalways gets two 32-byte buffers. - The server collects the request chunks into one
Bufferand verifies that, then parses JSON. The run shows GitHub's example (Hello, World!with the secretIt's a Secret to Everybody) givingsha256=757107ea…3e17, and every altered input failing. - Keep the secret out of the code: the server reads it from
WEBHOOK_SECRET.
Gotchas
timingSafeEqualthrowsERR_CRYPTO_TIMING_SAFE_EQUAL_LENGTHwhen the two buffers differ in length, so a short header crashes a handler that calls it unchecked. Validate the format first, as the regular expression does.Buffer.from(hex, "hex")stops silently at the first character that is not hex:Buffer.from("75zz07", "hex")is the single byte<Buffer 75>. Never decode a header without checking it is all hex digits.- The MAC covers bytes, not JSON. The same event re-serialized with
JSON.stringify(JSON.parse(body))loses the provider's whitespace and is rejected (401above), so verify the raw body before any body-parser middleware turns it into an object. - Node.js lower-cases incoming header names: read
req.headers["x-hub-signature-256"], whatever case the sender used. A missing header must fail the request, asverifydoes, not skip the check.