Hashes, passwords, and MACs

Authenticate messages with a MAC

Use HMAC or a high-level message-authentication API when two trusted parties share a secret and must detect forgery or modification.

A plain hash does not prove who created a message. Anyone can modify the message and calculate a new hash.

A message authentication code (MAC) fixes that by mixing a secret key into the computation. Without the key, an attacker cannot produce a valid tag for modified bytes. HMAC is the standard construction, and HMAC-SHA-256 is what most webhook signatures use.

Let’s compute a tag over a webhook body:

import { createHmac } from 'node:crypto'

const secret = process.env.WEBHOOK_SECRET
const tag = createHmac('sha256', secret).update(rawBody).digest('hex')
// e3b1c44298fc1c14...

The sender computes the tag and ships it in a header. The receiver recomputes it over the bytes it received and compares the two.

Verify the exact bytes

This is the failure that bites almost every webhook integration. The sender signs the raw JSON body. The receiver parses the JSON, then serializes it again before verifying. Whitespace and key order change, so the bytes change, and authentic requests fail.

Capture the raw request body before any JSON middleware touches it. Feed those exact bytes to the HMAC. In Express that means reading the body as a buffer, not as parsed JSON.

Compare in constant time

Never compare MACs with == or ===. String comparison stops at the first different character. That means the response time leaks how many leading characters matched, and an attacker can build a valid tag one byte at a time.

Use a constant-time comparison instead:

import { timingSafeEqual } from 'node:crypto'

const expected = createHmac('sha256', secret).update(rawBody).digest()
const received = Buffer.from(signatureHeader, 'hex')

const valid = expected.length === received.length &&
  timingSafeEqual(expected, received)

Notice the length check. timingSafeEqual throws when the two buffers have different lengths, so check that first.

What a MAC does not do

An HMAC proves that someone holding the shared secret created these exact bytes. It does not stop replay. Yesterday’s validly signed request verifies again today. Include a timestamp, a version, and a message type in the authenticated data, and reject stale timestamps or event IDs you have already seen.

It also gives no non-repudiation. Everyone who can verify holds the secret, so everyone who can verify can also forge. A valid tag cannot prove to a third party which side produced it. If you need that, you need a signature, and we’ll get there in the public-key module.

One more rule: keep keys separate by purpose. The webhook secret authenticates webhooks and nothing else.

Try this on your own: compute an HMAC over the exact raw bytes of a small webhook and verify it. Change one byte and verify again with the constant-time comparison. Then replay the original valid request and notice that it still passes, which is why you also need a timestamp or event ID.

Lesson completed