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.