loke.dev
Header image for Is Your 'Secure' Browser-Based Encryption Actually Vulnerable to a Timing Attack?

Is Your 'Secure' Browser-Based Encryption Actually Vulnerable to a Timing Attack?

A deep dive into why JavaScript's lack of constant-time primitives makes implementing secure cryptography in the browser a minefield for the unwary developer.

· 4 min read

It’s a little bit funny how much faith we put in a triple-equals sign. We spend weeks architecting these complex, end-to-end encrypted systems, and then we leave the front door unlocked with a simple if (a === b) check.

In the world of web security, "functional" and "secure" are often miles apart. You might have the most robust AES-GCM implementation running in your browser, but if your logic for verifying a MAC or comparing a sensitive token isn't constant-time, you're basically shouting your secrets to anyone with a stopwatch.

The "Fast" Logic That Fails You

JavaScript engines like V8 (Chrome/Edge) and SpiderMonkey (Firefox) are obsessed with speed. They want to get your code through the pipeline as fast as humanly possible. This leads to a behavior called short-circuiting.

Imagine you’re comparing two strings. If the first character doesn't match, the engine doesn't bother checking the rest of the string. It bails out immediately.

// This looks innocent, but it's a security nightmare for crypto
function insecureCompare(input, secret) {
    if (input.length !== secret.length) {
        return false;
    }
    return input === secret; // Short-circuits at the first mismatched byte
}

If an attacker wants to guess your 32-character API key, they can send a request starting with a, then b, then c. When they hit the right first character, the response will take a few microseconds longer because the engine had to check *two* characters instead of one.

Repeat this for every index, and suddenly, "secure" encryption is broken without ever cracking a cipher.

Why Browsers Make This Harder

In a low-level language like C, you can (usually) tell the compiler exactly what to do. In JavaScript, we live at the mercy of the Just-In-Time (JIT) compiler.

Even if you write what looks like constant-time code, the JIT might look at it and say, "Hey, I can optimize this loop!" and re-introduce the very timing leak you were trying to fix. Plus, we have the garbage collector kicking in whenever it feels like it, adding "noise" to our measurements.

But don't let the noise fool you. Statistical analysis can filter out that noise. If an attacker can make 100,000 requests to your application (which is trivial for a bot), the average timing difference becomes clear as day.

Using the Web Crypto API Correctly

If you're doing standard cryptographic operations—like verifying a signature or a MAC—do not write the comparison yourself. The browser's built-in SubtleCrypto API is designed to handle this under the hood using native, constant-time code.

Instead of pulling the signature out and comparing it with ===, let the browser do it:

async function verifyData(key, signature, data) {
  // The browser does this in a timing-safe way internally
  const isValid = await window.crypto.subtle.verify(
    "HMAC",
    key,
    signature,
    data
  );
  
  return isValid;
}

The subtle.verify() method is your best friend. It doesn't return the "correct" value for you to check; it simply returns a boolean, and it takes the same amount of time regardless of where a mismatch might occur.

When You Can't Use SubtleCrypto

Sometimes you aren't comparing hashes. Maybe you're comparing a session token, a CSRF secret, or a custom protocol identifier. Since SubtleCrypto doesn't have a subtle.compareStrings() method, you have to get creative.

To prevent short-circuiting, you need to ensure that every bit is touched, no matter what. We do this using bitwise operators.

Here is a practical implementation of a constant-time comparison for Uint8Array buffers:

function timingSafeEqual(a, b) {
  if (a.length !== b.length) {
    // Even this length check can leak information.
    // In a perfect world, you'd hash both first or pad them.
    return false;
  }

  let result = 0;
  for (let i = 0; i < a.length; i++) {
    // XORing two bits results in 0 if they are the same.
    // We OR the result with the XOR of the bytes.
    // If even one byte differs, 'result' will be non-zero.
    result |= a[i] ^ b[i];
  }

  // If result is still 0, every single byte matched.
  return result === 0;
}

// Usage:
const keyA = new Uint8Array([10, 20, 30, 40]);
const keyB = new Uint8Array([10, 20, 99, 40]);

if (timingSafeEqual(keyA, keyB)) {
  console.log("Match!");
} else {
  console.log("No match, but I took the long way to tell you.");
}

The "Double HMAC" Trick

If you're dealing with strings of different lengths (which is a massive timing leak in itself), a common "hack" is to hash both the input and the secret before comparing them.

By hashing both values with a secret key (using HMAC), the attacker can't predict what the resulting hash will look like. Since the hashes are always the same length, you've neutralized the length-leakage and the character-matching leakage in one go.

async function secureTokenCompare(providedToken, actualToken, secretKey) {
  const enc = new TextEncoder();
  
  // Hash both tokens using a secret HMAC key
  const h1 = await crypto.subtle.sign("HMAC", secretKey, enc.encode(providedToken));
  const h2 = await crypto.subtle.sign("HMAC", secretKey, enc.encode(actualToken));

  // Now compare the hashes. Even if this isn't perfectly constant-time, 
  // the attacker is guessing the HMAC output, not the token itself.
  return timingSafeEqual(new Uint8Array(h1), new Uint8Array(h2));
}

Wrapping Up

Timing attacks feel like something out of a spy movie—too academic to matter for your average SaaS app. But as our tools get better, so do the tools for exploitation.

The rule of thumb? Never use `===` for secrets. If you're in the browser:
1. Prioritize SubtleCrypto.verify().
2. Use bitwise operators to avoid short-circuiting when manual comparison is necessary.
3. Remember that the JIT compiler is faster than you, and it doesn't care about your security.

Writing secure code isn't just about using the right algorithm; it's about making sure your code doesn't accidentally whisper the answer while it's thinking.