~/blog

crypto.timingSafeEqual throws when the lengths differ

published

#node#security#crypto

TL;DR

crypto.timingSafeEqual(a, b) throws a RangeError when the two buffers differ in byte length, and the input is usually attacker-controlled — so the call that was supposed to fix a timing leak becomes a crash. Guarding it with a length check reintroduces the leak you were removing. Hash both sides to a fixed-width digest first, then compare the digests.

The problem

You find the classic timing leak in a token check:

if (req.headers['x-api-key'] === process.env.API_KEY) { /* ... */ }

=== on strings short-circuits at the first differing byte, so response time correlates with how many leading bytes the attacker got right. The standard fix is Node’s constant-time comparison:

import { timingSafeEqual } from 'node:crypto';

const ok = timingSafeEqual(
  Buffer.from(req.headers['x-api-key'] ?? ''),
  Buffer.from(process.env.API_KEY),
);

Ship that and the first request with a wrong-length key takes the process down:

RangeError [ERR_CRYPTO_TIMING_SAFE_EQUAL_LENGTH]: Input buffers must have the same byte length

Verified on Node v26.2.0. An attacker does not need to guess your key to trigger it — any header of the wrong length does, which turns an auth check into an unauthenticated denial of service.

Why it happens

A constant-time comparison walks both buffers to the end and accumulates a difference, so its runtime depends only on the length. It cannot do that over two different lengths, and rather than pick a behaviour that leaks, Node refuses the call. The Node docs are explicit that the function throws on a byte-length mismatch — the constant-time guarantee covers the byte-by-byte comparison once the lengths already match, nothing more.

It also only accepts binary types. Passing strings gives you a different error:

TypeError [ERR_INVALID_ARG_TYPE]: The "buf1" argument must be an instance of ArrayBuffer, Buffer, TypedArray, or DataView.

The guard that looks like a fix

The obvious patch is to check the length first:

// Do not do this.
const a = Buffer.from(req.headers['x-api-key'] ?? '');
const b = Buffer.from(process.env.API_KEY);
if (a.length !== b.length) return false;
return timingSafeEqual(a, b);

This stops the crash and puts a leak back. The early return false is fast; a length match falls through to the slower comparison. An attacker who can time your endpoint now learns the exact length of your secret, which is a meaningful chunk of the search space — and for a secret whose format implies its alphabet, sometimes most of it.

The guard that is also wrong on the length

There is a second version of that mistake, and it still crashes. String.prototype.length counts UTF-16 code units; timingSafeEqual compares bytes. Those diverge the moment anything is non-ASCII:

'café'.length            // 4
Buffer.byteLength('café') // 5
'cafe'.length            // 4
Buffer.byteLength('cafe') // 4

So a guard written against the strings passes — both are length 4 — and timingSafeEqual throws anyway on 5 bytes versus 4. If you are going to compare lengths at all, compare Buffer.byteLength, not .length. Though you should not be comparing lengths at all, which is the next section.

What to do

Hash both sides before comparing. A SHA-256 digest is 32 bytes no matter what went in, so the lengths always match, the call never throws, and the length of the secret never reaches the comparison:

import { createHash, timingSafeEqual } from 'node:crypto';

export function safeEqual(a, b) {
  const ha = createHash('sha256').update(a, 'utf8').digest();
  const hb = createHash('sha256').update(b, 'utf8').digest();
  return timingSafeEqual(ha, hb);
}

Verified output on Node v26.2.0:

equal:          true
diff same len:  false
diff lengths:   false
empty vs val:   false

The digest length is constant regardless of input size — createHash('sha256').update('a').digest().length and the same call over a 10,000-character string both return 32 — which is the whole point: the comparison now carries no information about how long either input was.

Hashing here is not about secrecy. Both values are already in your process. It is purely a length-normalisation step that happens to run in time independent of the content.

Caveats

References