Your Token Check Is a Timing Oracle

String equality exits at the first wrong byte. Attackers measure that and forge your tokens one character at a time.

Share

Somewhere in your code there's a line like if (token === storedToken). It looks harmless. It's a side channel: the time that comparison takes depends on how many characters the caller guessed correctly.

String equality is not constant-time. "Slower when more characters match" is exactly the signal an attacker needs to forge your tokens without ever brute-forcing the whole thing.

Why this matters

The target list is long: webhook signature checks, API key middleware, password-reset tokens, magic links. GitHub's own webhook docs tell you to use hmac.compare_digest instead of ==, because enough teams got burned.

The math is brutal. Guessing a 32-character alphanumeric token means 62^32 attempts. With a timing leak, the attacker needs roughly 32 × 62 guesses plus patience. Network jitter feels like protection, but it's just noise — averaged over thousands of samples, the signal wins. Researchers have pulled this off over the public internet, not just on localhost.

How it works

=== and memcmp compare left to right and bail at the first mismatch. Wrong first character: one character of work. Right first character: two. The delta is nanoseconds, but it's consistent — the attacker submits each candidate for position 0, averages response times across many requests, keeps the slowest one, and moves to position 1.

The length check leaks too. JavaScript checks string lengths before comparing characters, so the attacker learns exactly how long the secret is before guessing a single byte of it.

Where this helps

  • Webhook handlers: verifying HMAC signatures from GitHub, Stripe, or anyone else. Compare digests, never raw strings.
  • API key auth: custom middleware comparing an Authorization header against a stored key.
  • Password resets and magic links: single-use tokens plus a high-volume endpoint is the classic kill chain.
  • Signed URLs: same pattern, same fix.

Watch out

crypto.timingSafeEqual throws when the buffers have different lengths, which reintroduces the leak through the error. The standard fix: hash both sides with SHA-256 first, then compare the fixed-length digests.

Don't hand-roll a constant-time compare. JIT compilers and CPU micro-optimizations quietly break DIY XOR loops. Use the shipped primitive.

Storing raw tokens in Postgres and querying them is memcmp under the hood too — index comparisons can leak the same way. Store and look up SHA-256 hashes of tokens instead.

Rate limiting raises the attacker's cost but doesn't close the hole. Fix the comparison; treat throttling as defense in depth.

Try it yourself

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

const SECRET = 'tok_9f2ab71c3e';
const noMatch = 'tok_XXXXXXXXXX';   // 0 correct leading chars
const fourMatch = 'tok_9f2aXXXXXX'; // 4 correct leading chars

const bench = (candidate) => {
  let hits = 0;
  const t0 = process.hrtime.bigint();
  for (let i = 0; i < 1e6; i++) if (SECRET === candidate) hits++;
  return Number(process.hrtime.bigint() - t0) / 1e6;
};

console.log('0 matching:', bench(noMatch).toFixed(2), 'ms');
console.log('4 matching:', bench(fourMatch).toFixed(2), 'ms'); // slower = leak

// The fix: fixed-length digests + constant-time compare
const safeEqual = (a, b) =>
  timingSafeEqual(
    createHash('sha256').update(a).digest(),
    createHash('sha256').update(b).digest(),
  );

Every language ships the primitive: hmac.compare_digest in Python, crypto/subtle.ConstantTimeCompare in Go, hash_equals in PHP.

TL;DR

  • What's leaking: === and memcmp exit at the first wrong byte, so response time reveals how much of your secret the attacker guessed right.
  • Why it matters: it collapses brute-forcing a 32-char token from 62^32 attempts to a few thousand — webhooks, API keys, and reset tokens are all in scope.
  • Try today: hash both sides with SHA-256 and compare with timingSafeEqual, then audit every secret comparison in your services.