A webhook sender often signs a request body so its receiver can verify who supplied the bytes and whether they changed. This is a different job from storing passwords or issuing login tokens. The receiver must preserve the transmitted byte sequence, reproduce the documented signing algorithm, and reject an invalid signature before trusting the payload. This guide uses Node 24's built-in crypto module and a deliberately simplified HMAC-SHA256 contract. Real providers may include a timestamp, event identifier, prefix, or several signatures; follow their exact contract rather than adapting this fixture by guesswork.
Separate hashing, HMAC, and encryption
A cryptographic hash produces a fixed-size digest from input bytes. SHA-256 has a 32-byte output. Anyone can compute the same digest for the same input, so a hash supplied beside an untrusted download does not prove authenticity if an attacker can replace both. An expected digest becomes useful when it arrives through a separately trusted channel. Hashes are not encryption: they do not offer a reversible operation for recovering the original message.
An HMAC combines a message with a secret key. A party without that key should not be able to produce the required digest for a changed message. This provides integrity and shared-secret authentication, not confidentiality. Anyone who sees an unencrypted webhook body can still read it. Both parties possessing the key can sign messages, so an HMAC is not public proof that only one party authored a message. Use TLS for transport and keep signing secrets outside source control and ordinary request logs.
Sign the raw bytes before parsing
JSON whitespace, property order, and number formatting can change without changing the apparent application data. A sender that signs raw JSON bytes expects verification of those bytes, not verification of a newly serialized object. JSON.parse followed by JSON.stringify can produce a different sequence. Receive a bounded raw Buffer, verify it, then parse and validate the JSON. In Express, middleware order matters: a route using raw-body verification must obtain the raw data before a general JSON parser discards that representation.
Choose a maximum body size and handle an interrupted request. A signature check is not a reason to accumulate unlimited input. If a provider signs a timestamp joined with a delimiter and the body, reproduce that exact order and encoding. Do not trim trailing newlines, normalize Unicode, or reinterpret the body unless the provider's contract explicitly requires it. The fixture signs one Buffer and then changes only JSON whitespace; verification fails even though both strings can parse into equivalent objects.
Validate encodings before binary comparison
A digest can be displayed as hexadecimal or base64, but comparison should follow a carefully validated decoding step. Buffer.from(input, 'hex') can accept or truncate malformed hexadecimal text in ways that are unsuitable for a strict signature field. The fixture requires exactly 64 hexadecimal characters before decoding, matching a 32-byte SHA-256 HMAC. A different provider may use a different length or encoding, so this rule belongs to this signing contract rather than to every webhook.
timingSafeEqual compares equal-length byte sequences without a data-dependent byte comparison path. It throws if the lengths differ. Check decoded length before calling it. That length check does not reveal a valid signature's secret content; the output length is public. Constant-time comparison does not automatically make the surrounding verification code constant-time, nor does it remove the need to validate the message and key. Compare buffers, not their ordinary string representations, and avoid logging expected signatures or secrets while troubleshooting failures.
Add freshness and event processing policy
A valid signature proves that someone with the shared key signed these bytes. It does not prove that the request is new. A captured request can be sent again unless the receiver applies the provider's replay defenses. If a signed timestamp is available, check an appropriate freshness window with synchronized clocks. Record stable event identifiers so repeated delivery is idempotent. Providers legitimately retry webhooks, which means duplicate handling must distinguish harmless redelivery from a second business action.
Generate new secrets using cryptographically secure randomness such as randomBytes, not Math.random. Secret rotation may temporarily require verifying against both a current and previous key, with a defined retirement time. Treat comparisons and business processing as separate stages: first authenticate bytes, then validate payload structure, then claim or record an event, then perform the operation safely. A successful HMAC is not permission to trust arbitrary URLs, prices, or identifiers inside the JSON. The signature authenticates the sender; your application still enforces the event contract.
Node.js documentation and version notes
Node.js Integrity and Webhook HMAC Examples
Each example shows the code, the output it produces and what each part does.
Verifying an HMAC signature over raw bytes
The dependency-free fixture defines one verification function with explicit input rules. It prints only boolean results, not its key or digest. The valid signature authenticates the original buffer. Reformatting the JSON changes the bytes and therefore fails. A short signature is rejected before timingSafeEqual, while non-hexadecimal characters fail the format rule. The demo key is public test data and must never be copied into a deployed service. This example establishes the verification boundary in memory; an HTTP endpoint would also need bounded raw-body collection, provider-specific signature-header parsing, and the freshness policy described above.
import { createHmac, timingSafeEqual } from 'node:crypto';
import { Buffer } from 'node:buffer';
const key = Buffer.from('public-demo-key-do-not-deploy');
function sign(body) {
return createHmac('sha256', key).update(body).digest();
}
function verify(body, hex) {
if (typeof hex !== 'string' || !/^[0-9a-f]{64}$/i.test(hex)) return false;
const supplied = Buffer.from(hex, 'hex');
const expected = sign(body);
return supplied.length === expected.length && timingSafeEqual(supplied, expected);
}
const raw = Buffer.from('{"amount":10}');
const signature = sign(raw).toString('hex');
console.log(verify(raw, signature));
console.log(verify(Buffer.from('{ "amount": 10 }'), signature));
console.log(verify(raw, '00'));
console.log(verify(raw, 'z'.repeat(64)));true
false
false
falseNode.js Integrity and Webhook HMAC in Real Projects
Sign and verify timestamped webhook payloads
Implement a small signed-timestamp contract: sign the UTF-8 timestamp, a literal dot, and the raw body. Verify the signature and reject timestamps more than five minutes away from a supplied clock value. The solution takes the clock as an argument so the exercise is deterministic. It checks an integer timestamp shape before converting and compares the absolute difference, preventing a far-future timestamp from bypassing a one-sided age test. This still needs an event identifier store for deduplication in production. Run the complete example independently; its key and body are defined locally, and the resulting booleans do not depend on the machine's current time.
import { createHmac, timingSafeEqual } from 'node:crypto';
const key = 'another-public-test-key';
const body = Buffer.from('{"event":"paid","id":"e1"}');
function digest(timestamp, raw) {
return createHmac('sha256', key).update(timestamp).update('.').update(raw).digest();
}
function verify(timestamp, hex, now) {
if (!/^\d{10}$/.test(timestamp) || !/^[0-9a-f]{64}$/i.test(hex)) return false;
if (Math.abs(now - Number(timestamp)) > 300) return false;
const expected = digest(timestamp, body);
const supplied = Buffer.from(hex, 'hex');
return supplied.length === expected.length && timingSafeEqual(supplied, expected);
}
const timestamp = '1700000000';
const signature = digest(timestamp, body).toString('hex');
console.log(verify(timestamp, signature, 1700000100));
console.log(verify(timestamp, signature, 1700000400));true
falseNode.js Integrity and Webhook HMAC FAQ
Can I verify a parsed object instead of the raw body?
Only if the provider explicitly defines a canonical object serialization as its signing input. A raw-body contract requires the original bytes, including whitespace and line endings. Recreating JSON from an object can silently change that input. Retain a bounded Buffer for verification, then parse it after the signature succeeds. Middleware that parses first must be configured to preserve raw data or replaced on the webhook route.
Does timingSafeEqual accept signatures of any length?
No. Its two inputs must have equal byte lengths, and a mismatch throws. Validate the declared encoding and expected digest length before comparison. The length is a public property of the chosen algorithm. Returning false for a malformed signature is preferable to turning routine invalid input into an uncaught exception. Constant-time comparison protects the comparison operation; it does not excuse secret logging or insecure key storage.
Why is a plain SHA-256 checksum insufficient here?
A malicious sender can compute a new checksum for a changed payload. HMAC requires a secret that the malicious sender should not possess. A plain hash can still verify a file against an independently trusted expected checksum. The question is how the expected value becomes trusted, not whether SHA-256 can detect changed bytes. Webhook authentication usually needs a keyed or asymmetric signature scheme defined by the provider.
Will rejecting old timestamps stop every duplicate?
No. Two copies delivered inside the accepted window can both have valid timestamps and signatures. Deduplicate using the provider's stable event identifier and an atomic storage operation, then make processing idempotent. Timestamp checks reduce replay opportunities but do not replace business deduplication. Choose the allowed clock skew and retry behavior deliberately so legitimate delayed deliveries are handled according to the provider's documented expectations.
Node.js Integrity and Webhook HMAC Exercises
Answer each one and check it instantly. If you get stuck, open the hint before the answer.
What happens when the main fixture only changes JSON whitespace while keeping the original signature?
Node.js Integrity and Webhook HMAC Quiz
2 questions to check what you learned.
What does an HMAC provide for a webhook body?
- Integrity and authentication for parties sharing the secret
- Encryption that prevents observers reading the body
- A guarantee that the event has never been delivered before
Why check hexadecimal shape before Buffer.from?
- Decoding alone is not a strict signature-format validator
- Hexadecimal decoding always rejects every malformed character
- The check converts SHA-256 into a password hashing algorithm