Compare secrets

Check a key, token or code a client sent against the real one, without the check revealing where they differ.

secureCompare answers one question, whether two strings or byte sequences are equal, and it takes the same time whatever the answer is. That matters whenever one side is your secret and the other came in with a request: an API key in a header, a token from a link, a code from a form. With ===, someone patient can time the answer and rebuild your secret one character at a time; Concepts shows how.

#Before you start

  • It's synchronous and runs on every runtime listed in Installation.
  • Your value always goes first. The comparison runs for as many bytes as the first argument holds. In that order, its time depends on your secret, never on what the client sent.
  • You don't need it next to hmacVerify, totpVerify or argon2Verify. They compare through it already.
  • It answers equal or not. It doesn't limit how many guesses a client gets; rate limiting is yours.

#Check an API key from a header

This handler lets a request in only when its X-Admin-Key header holds the same key you put in the environment.

admin-key.ts
/** The key operators send in the `X-Admin-Key` header. */
const adminKey = process.env.ADMIN_API_KEY;

// An unset key would refuse every request without saying why.
if (!adminKey) throw new Error("ADMIN_API_KEY is not set");

export function isAdmin(request: Request): boolean {
  // Your value first, the request's second. A missing header is `null`.
  return secureCompare(adminKey, request.headers.get("x-admin-key"));
}
  • Fail at start-up: a missing key stops the process as it starts instead of surfacing on the first request, so a misconfigured deploy never runs.
  • No type checks on the header: headers.get returns null when a request has no such header, and secureCompare counts that as a mismatch. You don't need a guard before the call.
  • Argument order: adminKey first, the header second, so the time the check takes follows your key and not what the client sent.

#What the received value may be

The second argument is the client's, so it's untrusted: anything that isn't text or bytes counts as a mismatch, never an exception. That covers undefined from a missing database column, null from a missing header, and a number, array or object from a parsed JSON body.

Text and bytes mix freely, since a string is compared as its UTF-8 bytes:

secureCompare("hello", new TextEncoder().encode("hello")); // true

const body = JSON.parse('{"key": 12345}');
secureCompare("kq7VhM2xP9cL", body.key); // false, not a throw

#When it fails

Only a mistake on your side throws. Whatever the client sends, the worst it gets back is false:

SituationWhat happens
The values differ, in content or lengthReturns false.
received is null, undefined, a number, an array or an objectReturns false.
expected is "", null or undefinedReturns false, even when received is empty too. An empty secret never matches.
expected is any other type, such as a numberThrows INVALID_TYPE. That's a bug in your code, not in the request.
expected is empty and strict: true is setThrows OUT_OF_RANGE.

#Options

secureCompare(expected, received, options) has a single option:

OptionDefaultNotes
strictfalseThrow OUT_OF_RANGE instead of returning false when expected is empty.

Warning

Keep strict out of request handlers. A throw turns into a 500 while a mismatch turns into a 401 or 403. A client who notices the difference has learned that your secret is missing. Check for the secret at start-up, as the example does, and save strict for tests and start-up assertions.

#Going further

Worth knowing once you build on secureCompare or hold more than one secret:

  • Library authors: both sides accept a string, an ArrayBuffer, any typed array or a DataView. Pass along whatever your caller gave you; converting it first only adds one more place to get the encoding wrong.
  • Many valid keys: looping secureCompare over a list takes longer the more keys you hold. Store a digest of each key and look the digest up instead, the way the API tokens recipe does.
  • Encodings: two spellings of the same bytes, such as uppercase and lowercase hex, are different strings as far as secureCompare can tell. Decode both with the codecs and compare the bytes.