Why should a digest or authentication tag be compared using a constant-time comparison rather than an ordinary equality check, and when does it genuinely not matter?
answer
- early exit ⇒ time ∝ correct prefix length
- 2^128 offline → ~n×256 online probes
- jitter is random, the leak is systematic — average it away
- top rung: HMAC both sides with a fresh key, then compare
- rule: no secret-dependent branches or memory access
basics
~20 sAn early-exit comparison takes longer the more leading bytes match, so its runtime leaks how close a guess was. That turns an infeasible offline forgery search into a byte-at-a-time online search. It stops mattering only when both compared values are already public to the attacker.
solid answer
~50 sA byte-by-byte comparison that returns on the first mismatch makes verification time a function of the length of the correct prefix. If the attacker can submit many candidate tags against the same secret and observe any timing proxy, they solve one byte at a time: roughly 256 guesses per byte instead of 2^128 total. Network jitter does not save you — averaging over many samples recovers sub-microsecond differences, and colocated attackers see far less noise. The defence ladder applies: **structural separation** is the top rung — compare `HMAC(k, expected)` against `HMAC(k, supplied)` with a fresh key, so partial matches carry no usable information no matter how leaky the comparator; below that, **transformation** into a constant-time comparator that XOR-accumulates over a fixed length and branches once at the end; below that, **validation/detection** — rate limits and lockouts, which raise cost without removing the channel. It genuinely does not matter when both values are public, such as verifying a download against a published checksum.
code
text · 14 linesLEAKY:
for i in 0..n-1:
if a[i] != b[i]: return false # returns early → timing signal
return true
CONSTANT-TIME:
if len(a) != len(b): return false # ok for fixed-length digests only
diff = 0
for i in 0..n-1: diff |= a[i] ^ b[i] # always n iterations, one branch
return diff == 0
STRUCTURAL (comparator may be leaky and it still holds):
k = fresh_random_key()
return HMAC(k, a) == HMAC(k, b)go deeper
Say that comparing secrets byte by byte with an early return leaks how many bytes were right, and that a timing-safe comparison must be used for tags and digests.
Quantify the effect — one byte at a time, hundreds of probes per byte — and describe the accumulate-XOR-then-branch-once comparator and its pitfalls.
Place it on the defence ladder, prefer the blinded double-HMAC comparison as the structural fix, and identify the other observables (error paths, response size, logging) that carry the same signal.
State the general invariant — no secret-dependent control flow or memory access — make the safe comparator the platform default so individual sites need no audit, and note that a value's public/secret classification changes over a system's life.
## The channel The defect is not in the hash. It is in the comparison, and it belongs to a general class: **any computation whose control flow or memory access depends on secret data leaks that data through an observable resource** — time, cache state, power, branch predictor state. Digest comparison is the cleanest instance because the dependency is so direct. A naive comparator walks the two byte sequences and returns as soon as they differ. Its runtime is therefore proportional to the number of matching leading bytes. Consider an attacker probing an endpoint that verifies a tag they supply: ``` text guess 00 xx xx ... → reject after ~1 byte compared guess 01 xx xx ... → reject after ~1 byte ... guess 9f xx xx ... → reject after ~2 bytes ← first byte was right then fix 9f, vary byte 2, repeat ``` Each byte costs about 256 probes (or 16 if the tag is hex-encoded and compared as text). A 16-byte tag falls in a few thousand requests instead of 2^128 operations. The attack does not weaken the hash at all; it replaces the search with an oracle. ## What has to be true for it to be exploitable - The attacker can submit many verification attempts against the **same** secret. If each attempt uses a fresh nonce or key, the partial information does not accumulate. - Some observable correlates with the comparison — wall-clock response time is the obvious one, but so are queue depth, a distinguishable error path taken later, a log line, or a response-size difference. - The signal survives the noise. This is where candidates get complacent. Jitter is *random*; the leak is *systematic*. Averaging N samples reduces the noise by roughly sqrt(N), so a few thousand extra samples per probe recover differences well below the noise floor. Remote timing attacks over ordinary networks have been demonstrated repeatedly, and an attacker on the same host, the same hypervisor or the same datacentre sees far less noise than "the internet is jittery" implies. ## The defence ladder Use the same ordering that governs the rest of secure coding — **structural separation > escaping/transformation > validation > detection** — weakening from guarantee to heuristic at each step down. **Structural separation (top rung).** Remove the attacker's ability to steer the comparison at all. Generate a random key `k` per verification and compare `HMAC(k, expected)` with `HMAC(k, supplied)`. The bytes actually compared are unpredictable to the attacker, so learning that the first three of them matched tells them nothing about the tag. This is a guarantee that holds even if the comparator underneath is the platform's ordinary, leaky equality — which is exactly why it is the top rung: it removes the class rather than mitigating an instance. **Transformation (second rung).** A constant-time comparator: fixed-length inputs, accumulate the differences with an OR of XORs across every byte, and take exactly one branch at the very end. ``` text diff = 0 for i in 0 .. n-1: diff = diff OR (a[i] XOR b[i]) # no early exit, no data-dependent branch return diff == 0 ``` This is a transformation rung, not a structural one, because its correctness depends on modelling the machine: a compiler may reintroduce a branch, a JIT may eliminate the loop, the runtime may intern or short-circuit strings, and an up-front length check leaks the length (harmless for a fixed-length digest, dangerous for a variable-length secret). Comparing after a decode step whose decoder itself short-circuits reopens the channel. **Validation / detection (lower rungs).** Rate limiting, lockout, anomaly alerting on repeated failures. These raise the attacker's cost and make the campaign visible; they do not close the channel, and a patient attacker under the threshold still wins. They belong in the design, never as the answer. ## When it genuinely does not matter When the attacker already knows both values. Verifying a downloaded file against a checksum that is published openly leaks nothing, because there is no secret in the comparison. Likewise, comparing two values that the attacker cannot submit repeatedly, or a one-shot comparison against a secret that is rotated on every use. Two caveats worth voicing: 1. "Public" is a claim about today. A value that becomes a capability later — a checksum that becomes an approval token, an id that becomes a bearer token — silently converts a safe comparison into a leaky one, with no code change to notice. 2. The cost of the safe comparator is negligible. Making it the default and reasoning only about exceptions is cheaper than auditing each site. ## Generalising The rule to state is not "use a timing-safe compare for HMACs". It is: **secret-dependent control flow and secret-dependent memory access are observable**. That single rule also explains why table-driven cipher implementations leak through the cache, why secret-dependent branch counts leak, and why modular exponentiation is implemented with a fixed ladder. Digest comparison is the version of that rule an application developer will actually meet.
- Someone argues that network jitter makes remote timing attacks impossible. Respond.Jitter is zero-mean random noise while the leak is a systematic bias, so averaging over many samples separates them: precision improves roughly with the square root of the sample count, and an attacker who can send tens of thousands of requests recovers differences far below the per-request noise. The argument also assumes the worst-case attacker position, when in practice attackers are often colocated in the same datacentre, region or hypervisor where the noise floor is orders of magnitude lower. Finally, timing is only one observable — an error path, a log write or a response-size difference can carry the same signal with no noise at all.
- Why does hashing both sides with a fresh random key before comparing work even if the comparison itself is not constant-time?The attacker can predict neither the key nor therefore the byte sequences that are actually compared, so the length of the matching prefix in that comparison is uncorrelated with how close their guess was to the real tag. The timing signal still exists but carries no information they can accumulate across probes. That is why it sits on the structural-separation rung: it eliminates the attacker's control over the leaked quantity rather than trying to suppress the leak.
saying these in an interview costs you the question
- "Network noise hides it" — averaging defeats random noise; the bias is systematic.
- Using the language's default string or array equality on a secret tag because it "looks the same".
- Adding rate limiting and calling the timing channel closed — that is the detection rung, not a fix.
- Believing constant-time comparison protects a weak or guessable tag; it protects the comparison, not the secret's entropy.
- Comparing decoded values with a decoder that itself short-circuits, or leaking the secret's length via an up-front length check on a variable-length secret.