A cryptographic hash turns any input into a fixed-length digest, cannot be run backwards, and cannot be made to collide on purpose. createHash(alg) returns a stream you feed with update() and finish with digest(), which may be called only once — a second call throws ERR_CRYPTO_HASH_FINALIZED. Node 21.7 added the one-shot crypto.hash(alg, data, encoding).
A digest proves nothing about who produced it. HMAC mixes a secret key into the hash, so only a key holder can produce a valid tag. Compare tags with timingSafeEqual, never ===: string comparison stops at the first differing byte, and an attacker who times that gap recovers the tag byte by byte.
import { createHash, createHmac, timingSafeEqual } from 'node:crypto';
console.log('sha256:', createHash('sha256').update('order-4711').digest('hex'));
const key = Buffer.from(process.env.WEBHOOK_SECRET ?? 's3cret-webhook-key');
const body = '{"event":"invoice.paid","id":"in_9f2"}'; // RAW body, before JSON.parse
const header = createHmac('sha256', key).update(body).digest('hex');
function isFromProvider(rawBody, signature) {
const expected = createHmac('sha256', key).update(rawBody).digest();
const received = Buffer.from(signature, 'hex');
return expected.length === received.length && timingSafeEqual(expected, received);
}
console.log('x-signature:', header);
console.log('genuine :', isFromProvider(body, header));
console.log('modified:', isFromProvider(body + ' ', header));sha256: 173d0ddbff034193face7ce60d3f58b416940a4c29306f9af512e4e8f2bf61e3 x-signature: eaf7e4ff133f76ab2b72798ddf570fb9dfbf1de464cf1b7c71295e4112303702 genuine : true modified: false
Hash the raw request body, not a re-serialized object — JSON.stringify(JSON.parse(body)) reorders keys and the tag stops matching, so in Express 24,430 use express.raw({ type: 'application/json' }) on that route (Express.js). Sign a timestamp with the body, or a captured request can be replayed forever.
SHA-256 is the default: 64 MiB took 131 ms here, SHA-512 only 86 ms, since it works in 64-bit words. MD5 and SHA-1 survive in getHashes() for old file formats, but chosen-prefix SHA-1 collisions have been public since 2017.