secureCompare()

Constant-time equality. The trusted value goes first, and anything odd from the wire is a mismatch rather than an exception.

import { secureCompare } from "unsecure/compare";

secureCompare(storedToken, submittedToken); // boolean

#Signature

function secureCompare(
  expected: string | BytesSource | undefined,
  received: string | BytesSource | null | undefined,
  options?: { strict?: boolean },
): boolean;

#Why it exists

=== on strings stops at the first differing character. The time that takes is measurable, and an attacker who can measure it recovers a secret one character at a time. secureCompare reads the same number of bytes whatever the input, so the duration carries no information about where the values diverged.

#The argument order is the contract

The first argument determines the loop length.

// Leaks the length of the attacker's input
secureCompare(userInput, serverToken);

// The trusted value first
secureCompare(serverToken, userInput);

#What each argument accepts

expected is yours, so a wrong type is your bug and throws:

secureCompare(12_345 as any, "x");
// UnsecureError INVALID_TYPE: secureCompare: expected a string, ArrayBuffer or ArrayBuffer view, got number.

received is untrusted, so anything that is not text or bytes is simply a mismatch:

secureCompare("expected", undefined); // false — a missing database column
secureCompare("expected", null); // false — a missing header
secureCompare("expected", 12_345 as any); // false — a number out of a JSON body
secureCompare("expected", [1, 2, 3] as any); // false — an array out of a JSON body

Both sides take a string or any BytesSource, and mixing them is fine — a string is compared as its UTF-8 bytes.

secureCompare("match", "match"); // true
secureCompare("hello", new TextEncoder().encode("hello")); // true
secureCompare(new Uint8Array([1, 2, 3]), new Uint8Array([1, 2, 3])); // true

#Empty and missing expected

By default an empty or undefined expected returns false, the same answer a mismatch gives:

secureCompare("", "something"); // false
secureCompare(undefined, undefined); // false — never "empty matches empty"

{ strict: true } throws instead:

secureCompare(undefined, "x", { strict: true });
// UnsecureError OUT_OF_RANGE

#Options

OptionTypeDefaultWhat it does
strictbooleanfalseThrow OUT_OF_RANGE when expected is empty or undefined, instead of returning false.

#Pitfall: strict: true in a request handler

strict: true turns a missing server-side value into a throw, which your framework turns into a 500 while a wrong signature stays a 403. An attacker can tell those apart, which makes the option a side channel in any code path they can reach.

// Distinguishable by status code
const valid = secureCompare(serverSecret, userInput, { strict: true });

// Default mode, plus an explicit check at boot
if (!serverSecret) throw new Error("BOOT: serverSecret is not configured");
const valid = secureCompare(serverSecret, userInput);

Keep strict for start-up assertions and tests.

#Note

hmacVerify, hotpVerify, totpVerify and argon2Verify all use secureCompare internally. You do not need to call it yourself when you are using those.