← All articles
SecurityJavaScriptCryptography

Timing-safe equal: comparing secrets without leaking them

Why a normal string comparison can leak confirmation tokens byte by byte, how timingSafeEqual fixes it, and a live benchmark you can run in the browser.

Axel Isouard

· 9 min read

I recently wired a newsletter confirmation flow: generate a random token, store it, send it by email, then check it again when the subscriber clicks the link. The naive check looks innocent:

if (storedToken !== token) {
  throw new Error('Invalid confirmation link');
}

That works. It is also the kind of comparison that can quietly leak information to anyone who can measure how long your server takes to say “no.”

This article walks through why that matters, how a timing-safe equal works, and how to use the small helper I ended up with. There is a live benchmark below so you can feel the difference instead of taking my word for it.

The problem with early exits

When JavaScript compares two strings with ===, or when you write a loop that returns on the first mismatch, the comparison stops as soon as it finds a different character.

function naiveEqual(a, b) {
  if (a.length !== b.length) {
    return false;
  }

  for (let i = 0; i < a.length; i++) {
    if (a.charCodeAt(i) !== b.charCodeAt(i)) {
      return false; // leaves early
    }
  }

  return true;
}

If the secret is cafe1234 and the attacker tries aaaa1234, the function bails out on the first byte. If they try cafe9999, it walks four characters before failing. Those extra iterations take a little more time.

Over many requests, that tiny gap becomes a signal. An attacker can recover the token one character at a time: fix the first byte, measure which guess is slowest, lock it in, move to the next byte, repeat. That class of bug is called a timing attack.

You do not need NASA-grade hardware for this to matter. Confirmation links, unsubscribe tokens, API keys, HMAC digests, and session secrets are all fair game once an attacker can hit your endpoint and read response times with enough samples.

What “timing-safe” actually means

A timing-safe comparison does not try to be clever about when to stop. It always looks at every byte, then decides.

The usual trick is to XOR the bytes and accumulate any difference into a single integer. If the accumulator is still zero at the end, the buffers matched. If anything differed, some bit will be set.

function timingSafeEqual(a: string, b: string): boolean {
  const encoder = new TextEncoder();
  const left = encoder.encode(a);
  const right = encoder.encode(b);

  if (left.byteLength !== right.byteLength) {
    return false;
  }

  let diff = 0;
  for (let i = 0; i < left.length; i += 1) {
    diff |= left[i]! ^ right[i]!;
  }

  return diff === 0;
}

A few details are worth calling out:

  • The length check still returns early. That leaks the length, not the contents. For fixed-length tokens (mine are 32 random bytes rendered as 64 hex characters), the attacker already knows the length.
  • We compare UTF-8 bytes via TextEncoder, not JavaScript string indexes. That keeps the work closer to what the platform primitives expect: Buffer / Uint8Array / ArrayBuffer.
  • The XOR / OR loop never branches on equality. Branchless code is what keeps the runtime flat across matching and mismatching inputs of the same length.

You do not have to ship that loop yourself when the runtime already has one. Node.js and Bun expose crypto.timingSafeEqual. Cloudflare Workers expose the same idea as a non-standard Web Crypto extension: crypto.subtle.timingSafeEqual.

const encoder = new TextEncoder();
const left = encoder.encode(storedToken);
const right = encoder.encode(token);

if (left.byteLength !== right.byteLength) {
  return false;
}

return crypto.subtle.timingSafeEqual(left, right);

The hand-rolled version above is still useful as a mental model, and as a fallback anywhere neither primitive exists. On Workers, prefer the built-in.

Live benchmark

Theory is nice. Numbers are better. The demo below compares a naive early-exit byte loop against the timing-safe XOR loop on the same secret, with candidates that share an increasing prefix. Both sides work on Uint8Array values, which is what the string helper spends its variable time on after TextEncoder.

Click Run benchmark. Each bar is the average time per comparison after many iterations. On a timing-leaky compare, longer matching prefixes should take longer. On a timing-safe compare, the bars should stay roughly flat.

Browsers, JITs, thermal throttling, and tab backgrounding all inject noise. Run it a couple of times. What you are looking for is the shape: the naive series trends upward as the shared prefix grows, while the timing-safe series does not care where the first mismatch sits.

Caveats worth knowing

Timing-safe equal is necessary, not sufficient.

Node’s own docs are blunt about this: surrounding code can still leak. Logging the token, returning different error messages, taking a slow database path only on “token found,” or branching on email before comparing the secret can undo the whole effort.

A few practical rules I stick to:

  1. Keep secrets fixed-length when you can. Random 32-byte tokens are easy to reason about.
  2. Compare digests the same way. If you store sha256(token), compare the digests with a timing-safe helper, not the raw strings with ===.
  3. Prefer the platform primitive when it exists: crypto.timingSafeEqual in Node, crypto.subtle.timingSafeEqual on Workers.
  4. Do not “optimize” a hand-rolled loop by returning early once diff !== 0. That reintroduces the bug you just removed.

What’s next

Anytime you compare a secret you did not invent in the same request, reach for a timing-safe equal. Confirmation tokens, unsubscribe links, webhook signatures, API keys, password-reset codes: same pattern every time.

On Node:

import { timingSafeEqual } from 'node:crypto';

const left = Buffer.from(storedToken);
const right = Buffer.from(token);

const ok =
  left.length === right.length &&
  timingSafeEqual(left, right);

On Cloudflare Workers:

const encoder = new TextEncoder();
const left = encoder.encode(storedToken);
const right = encoder.encode(token);

const ok =
  left.byteLength === right.byteLength &&
  crypto.subtle.timingSafeEqual(left, right);

Encode the strings, check the lengths, then let the runtime do the constant-time compare. The important part is remembering to call it.

Share article