Skip to content
ecdsa.com

HMAC-SHA256 sign/verify · Node.js

How to sign and verify a message with HMAC-SHA256 in Node.js

To sign and verify a message with HMAC-SHA256 in Node.js, use createHmac from node:crypto to compute the MAC and timingSafeEqual to compare it in constant time. The typical case is a webhook: the sender puts sha256=<hex> in a header, and the receiver recomputes the HMAC over the raw request body. This recipe checks GitHub's documented example and runs a small node:http receiver.

Tested with Node.js 24.22.0 (node:crypto and node:http, OpenSSL 3.5.8).

webhook.mjs — sign and verify an X-Hub-Signature-256 style header
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 time
export 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
}
server.mjs — a node:http receiver that verifies the raw body before parsing JSON
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

  1. createHmac("sha256", secret).update(rawBody).digest("hex") is the whole signature: 64 hex digits, prefixed with sha256= as GitHub, and many providers after it, send it.
  2. 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.
  3. The regular expression accepts exactly sha256= plus 64 hex digits, so a missing, truncated or garbled header fails before any comparison, and timingSafeEqual always gets two 32-byte buffers.
  4. The server collects the request chunks into one Buffer and verifies that, then parses JSON. The run shows GitHub's example (Hello, World! with the secret It's a Secret to Everybody) giving sha256=757107ea…3e17, and every altered input failing.
  5. Keep the secret out of the code: the server reads it from WEBHOOK_SECRET.

Gotchas

  • timingSafeEqual throws ERR_CRYPTO_TIMING_SAFE_EQUAL_LENGTH when 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 (401 above), 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, as verify does, not skip the check.

Related recipes