A digest comparison has two separate responsibilities: interpreting the supplied text and comparing the resulting bytes. Passing arbitrary text directly into a hex decoder can accept representations your application did not intend. Passing unequal-length buffers to timingSafeEqual throws instead of returning false. Define the text boundary first, then perform the byte comparison.
Prerequisites: Node.js with built-in node:crypto and node:assert/strict. The example was executed on Node.js 26.13.0. It uses a public synthetic digest, no credentials and no external packages.
Define a fixed representation
This application's digest is exactly 32 bytes represented by 64 hexadecimal characters. Uppercase and lowercase hex are both accepted because they decode to the same bytes. Whitespace, prefixes, odd lengths and nonhex characters are rejected. A malformed configured digest throws; malformed supplied input returns false.
The official Buffer documentation describes hex decoding, including truncation behavior for malformed input. The crypto documentation requires the compared buffers to have the same byte length.
Complete executable example
Save as digest.mjs.
import { timingSafeEqual } from 'node:crypto';
import assert from 'node:assert/strict';
function decodeDigest(text) {
if (typeof text !== 'string' || !/^[0-9a-fA-F]{64}$/.test(text)) return null;
return Buffer.from(text, 'hex');
}
function equalDigest(expectedHex, suppliedHex) {
const expected = decodeDigest(expectedHex);
if (expected === null) throw new TypeError('invalid configured digest');
const supplied = decodeDigest(suppliedHex);
return supplied !== null && timingSafeEqual(expected, supplied);
}
const fixture = 'ab'.repeat(32); // Public synthetic digest, never a credential.
assert.equal(equalDigest(fixture, fixture.toUpperCase()), true);
assert.equal(equalDigest(fixture, 'ac'.repeat(32)), false);
for (const bad of ['', 'a'.repeat(63), fixture + '00', 'zz'.repeat(32), fixture + '\n', null]) {
assert.equal(equalDigest(fixture, bad), false);
}
assert.throws(() => equalDigest('bad', fixture), TypeError);
console.log('digest checks passed: match, mismatch, malformed values, bad configuration');
Run and inspect
node digest.mjs
Expected output:
digest checks passed: match, mismatch, malformed values, bad configuration
Every accepted text value decodes to exactly 32 bytes. That invariant means the comparison receives equal-length buffers. The uppercase test checks the chosen representation policy, while the different valid digest confirms that syntactically valid input is not automatically accepted. The final assertion distinguishes a configuration problem from an ordinary mismatch.
Edge cases and troubleshooting
Do not trim input automatically unless whitespace is explicitly allowed by your protocol. A trailing newline is rejected here and is covered by an executable test. If a configuration value fails, inspect how it was loaded rather than weakening the validation expression. A protocol that includes a prefix or identifies the digest algorithm needs an explicit parser for that additional structure.
This helper compares already computed values. It does not calculate a message authentication code, establish which algorithm produced a digest, bind a digest to a request body or prevent replay. A plain unkeyed digest is not proof of who sent a message.
The use of timingSafeEqual does not make the entire function or an HTTP handler constant-time. Type checks, parsing, rejected-input paths and surrounding response behavior can differ. Node's documentation explicitly cautions about surrounding code. The tests verify functional outcomes, not timing resistance; no timing measurements or authentication-system audit were performed.
Explore more node.js tutorials and Javascript tutorials.