DevDockTools

How to Compare Two UUIDs Without Leaking Timing Info

Naive string comparison of UUIDs can leak information through timing side channels. Here's when it matters and how to compare securely in JavaScript, Python, and Go.

By Daniel Agrici6 min read
uuidsecuritytiming attackscryptographyjavascript

If you're comparing two UUIDs to decide whether two rows are "the same record," a plain === is fine. If you're comparing a UUID against one submitted by a client — a bearer token, an unsubscribe link, an API key formatted as a UUID — and that comparison happens in a network-observable request, the comparison method itself can become a vulnerability. Here's the actual distinction, and how to fix it when it applies.

The Timing Attack, Concretely

Most string equality implementations, including JavaScript's ===, Python's ==, and naive strcmp-based comparisons, are short-circuiting: they compare characters left to right and return as soon as they find a mismatch. That means comparing "a1b2..." against a stored "a1b2c3..." takes marginally longer than comparing it against "z9y8...", because more characters match before the loop bails out.

For a single comparison, that timing difference is noise — sub-microsecond, buried in network jitter. But an attacker who can make thousands or millions of requests against an endpoint that does this comparison can statistically recover the correct value one character (or nibble) at a time, because responses where more prefix characters match will be measurably slower on average across enough samples. This is a well-documented class of side-channel attack and the reason libraries like Node's crypto.timingSafeEqual exist.

When a UUID Is Actually a Secret

The distinction that matters:

  • UUID as an identifier — a primary key, a request-tracing ID, a resource path segment (/orders/{uuid}) that's paired with a separate authorization check. Comparing these with plain equality is fine; there's nothing secret to leak.
  • UUID as a bearer credential — an API key, a password-reset token, a magic-link token, a webhook signing secret formatted as a UUID for convenience. If possession of the UUID alone grants access, and an endpoint responds differently (even by microseconds) based on how much of it matches, you have a genuine timing side channel.

If you're only ever doing membership lookups (WHERE id = $1 in a database), the comparison happens inside the database engine over a connection where an attacker can't isolate the microsecond-level timing signal from network variance — so this is almost exclusively an application-layer concern for credential-like UUIDs compared directly in your request-handling code.

Constant-Time Comparison in JavaScript

Node's built-in crypto module provides timingSafeEqual, which compares buffers of equal length in constant time regardless of where they diverge:

const crypto = require('crypto');

function safeCompareUUIDs(a, b) {
  const bufA = Buffer.from(a, 'utf8');
  const bufB = Buffer.from(b, 'utf8');

  // timingSafeEqual throws if lengths differ, which is itself
  // a length-based side channel — normalize first.
  if (bufA.length !== bufB.length) {
    return false;
  }

  return crypto.timingSafeEqual(bufA, bufB);
}

safeCompareUUIDs(
  '550e8400-e29b-41d4-a716-446655440000',
  userSuppliedToken
);

The length check before calling timingSafeEqual is intentional and safe here: valid UUIDs are a fixed 36 characters, so a length mismatch alone doesn't reveal which characters are correct — it just confirms malformed input, which you'd reject anyway.

Constant-Time Comparison in Python and Go

Python's standard library has hmac.compare_digest, designed for exactly this:

import hmac

def safe_compare_uuids(a: str, b: str) -> bool:
    return hmac.compare_digest(a, b)

Go's crypto/subtle package provides ConstantTimeCompare:

import "crypto/subtle"

func safeCompareUUIDs(a, b string) bool {
    if len(a) != len(b) {
        return false
    }
    return subtle.ConstantTimeCompare([]byte(a), []byte(b)) == 1
}

All three follow the same pattern: normalize to fixed-length byte representations, then hand off to a comparison function that's documented to run in time independent of where the inputs diverge.

A Better Fix: Don't Compare the Raw UUID at All

Constant-time comparison closes the timing channel, but the more robust pattern for anything credential-like is to avoid comparing the raw secret at all. Hash the incoming token and compare hashes, or better, store only a hash of the token server-side (like you would a password) and verify against that:

const crypto = require('crypto');

function hashToken(token) {
  return crypto.createHash('sha256').update(token).digest('hex');
}

// Store hashToken(token) at creation time.
// At verification time, hash the incoming value and compare
// against the stored hash with timingSafeEqual.

This has a side benefit beyond timing safety: if your database is ever exposed, an attacker gets hashes instead of usable bearer tokens directly.

Common Mistakes That Silently Reintroduce the Leak

Switching to timingSafeEqual doesn't automatically make a code path timing-safe end to end — the leak often creeps back in around the comparison rather than inside it:

  • A fast-path short-circuit before the comparison. Code like if (userToken.length !== storedToken.length) return false; else return timingSafeEqual(...) looks safe but still leaks length information through timing if length itself is unpredictable to the attacker (it usually isn't for fixed-format UUIDs, but it matters for variable-length tokens using the same pattern).
  • Database or cache lookups keyed by the secret before the comparison runs. db.findOne({ token: userSuppliedToken }) performs an equality check inside the database, and depending on the database engine and indexing, that lookup can itself have input-dependent timing (index traversal, cache hits on partial matches) — separate from anything you fix in application code.
  • Logging or error messages that vary by how much matched. Even with a constant-time comparison, a debug log that prints "token mismatch at position 14" or an error message that differs for "malformed" versus "well-formed but wrong" defeats the purpose — the side channel just moves from timing to response content.
  • Early returns elsewhere in the request handler. If authentication is one of several checks in a handler (rate limiting, then auth, then business logic), make sure none of the other checks have input-dependent timing that correlates with the secret — the constant-time guarantee only covers the one comparison you fixed.

The general principle: constant-time comparison protects one function call, not the whole request lifecycle. Anywhere the secret influences control flow, a lookup, or an error path, walk through whether that path's timing (or content) could vary with how "close" the guess was.

Where This Fits With UUID Generation

None of this matters if the UUID itself is predictable. A UUID used as a secret must come from a cryptographically secure random source — UUIDv4 generated with a proper CSPRNG, not a UUIDv1 (which embeds a timestamp and MAC address) or a sequential/incrementing scheme. If you need to generate test UUIDs to verify this comparison logic, the UUID Generator produces standards-compliant v4 identifiers for exactly that kind of local testing — just don't use output pasted from a browser tool as an actual production secret, generate those server-side with your language's CSPRNG.

Audit your codebase for ===, ==, or .equals() on any UUID that doubles as a credential, and swap those specific comparisons — not every UUID comparison in your app needs this treatment, just the ones where the UUID's secrecy is doing security work.

Frequently Asked Questions

Do I need constant-time comparison for every UUID in my app?
No. Only when the UUID functions as a secret — an API key, session token, or password-reset identifier — and an attacker can measure response timing. Comparing a UUID used purely as a database primary key doesn't need it.
Does === in JavaScript leak timing information?
Yes, for strings. JavaScript's string equality typically short-circuits on the first mismatched character, so comparison time correlates with how many leading characters match, which is exactly the signal a timing attack exploits.
Is a constant-time comparison slower in a way that matters?
For a 36-character UUID string, the difference is irrelevant to application performance. Constant-time comparison always processes the full length regardless of where a mismatch occurs, but this adds no measurable overhead for identifiers this short.