Why should you compare two digests with MessageDigest.isEqual instead of Arrays.equals or ==?
answer
- isEqual = constant-time, no early return
- Arrays.equals short-circuits → timing leak
- Timing side-channel: learn the secret byte by byte
- == compares references, always wrong for byte[]
- Matters most for MAC/HMAC/token verification
basics
~20 sMessageDigest.isEqual compares byte arrays in constant time, so it does not leak how many bytes matched. Arrays.equals returns early on the first mismatch, which an attacker can time to guess a secret byte by byte.
solid answer
~50 sWhen you compare a computed digest (or HMAC/MAC) against an expected value that depends on a secret, the comparison itself can leak information through timing. Arrays.equals (and ==) short-circuit: they return false at the first differing byte. An attacker who can measure how long the comparison takes can therefore learn how many leading bytes were correct and brute-force the secret one byte at a time — a timing side-channel attack. MessageDigest.isEqual is implemented to run in time independent of how many bytes match (constant-time / time-constant comparison): it examines every byte and accumulates differences without an early return, so the timing reveals nothing about where the mismatch occurred. Use isEqual whenever you validate a MAC, an HMAC, a token, or any digest tied to a secret. For plain integrity checks of non-secret data the timing leak is harmless, but isEqual is a safe default. Note == on byte[] only compares references, never contents, so it is always wrong here.
code
java · 9 linesimport java.security.MessageDigest;
// Verifying a MAC tied to a secret: use constant-time compare.
boolean verify(byte[] expectedMac, byte[] computedMac) {
// Safe: constant-time, no early return -> no timing side-channel.
return MessageDigest.isEqual(expectedMac, computedMac);
// UNSAFE here: Arrays.equals(expectedMac, computedMac) short-circuits.
// WRONG: expectedMac == computedMac compares references, not bytes.
}go deeper
Knows to use MessageDigest.isEqual to compare digests and that == on arrays compares references, not contents.
Can explain the timing side-channel: Arrays.equals short-circuits, isEqual is constant-time, and why this matters for MAC/token checks.
Distinguishes secret-dependent comparisons (need constant-time) from public integrity checks, and knows constant-time compare is necessary but not sufficient against side-channels.
Sets a codebase-wide rule (lint/review) to never compare secret-derived bytes with Arrays.equals/==, and understands the broader side-channel threat model and JIT pitfalls of hand-rolled constant-time code.
## The setup: comparing a digest you can't trust Many security checks reduce to *"does the digest/MAC I just computed equal the one I was given?"*. Examples: verifying an HMAC on a signed cookie or webhook, checking a download against a known hash, confirming an API token. The naive code is: ```java if (Arrays.equals(computed, expected)) { /* accept */ } ``` This is *functionally* correct but can be *cryptographically unsafe* when `expected` (or `computed`) is derived from a secret the attacker is trying to forge. ## What a timing side-channel is A **side-channel attack** extracts secrets not by breaking the math but by observing a physical/behavioral byproduct of the computation — here, **how long it takes**. `Arrays.equals` walks the two arrays and **returns the moment it finds a mismatch** (it *short-circuits*). So: - If the first byte already differs, it returns almost instantly. - If the first 10 bytes match and the 11th differs, it takes slightly longer. An attacker who can submit guesses and *measure the response time* (even averaged over many requests to beat the noise) can therefore tell *how many leading bytes of their guess were correct*. They fix byte 0, vary it until the timing jumps, lock it in, move to byte 1, and so on. This turns an infeasible 256^32 brute force into a feasible ~256×32 search. This is the classic attack against non-constant-time MAC verification. ## What constant-time comparison means A **constant-time** (a.k.a. *time-constant*) comparison takes essentially the same amount of time regardless of the input, so the timing carries no information about the data. The implementation trick is to **never branch on the data and never return early**: examine *every* byte and OR the differences together, e.g. ```java int result = a.length ^ b.length; for (int i = 0; i < a.length; i++) { result |= a[i] ^ b[i]; } return result == 0; ``` Equal bytes contribute 0; any difference sets bits in `result`; the loop always runs to completion. `java.security.MessageDigest.isEqual(byte[], byte[])` is specified and implemented to be such a time-constant comparison (it has been since Java 6u17), which is exactly why it lives on `MessageDigest` even though it's just a byte-array compare. ## Why == is doubly wrong `a == b` on two `byte[]` compares **object references**, not contents — two distinct arrays with identical bytes are `!=`. So `==` is not just timing-unsafe, it is *functionally* wrong for value comparison. Never use it to compare digests. ## When does it actually matter? - **Use isEqual** whenever the comparison gates access based on a secret: HMAC/MAC verification, token/CSRF/session checks, password-reset codes, signature tags. This is the high-value case. - For **integrity-only** checks of public data (e.g. "does this downloaded ISO match the published SHA-256?") the values aren't secret, so a timing leak gives the attacker nothing they don't already know. `Arrays.equals` is fine there — but `isEqual` is a harmless, safe default, so many teams just always use it. ## Pitfalls - Constant-time *byte comparison* is necessary but not sufficient: if your surrounding code branches on the secret elsewhere, you can reintroduce a leak. - `isEqual` compares full arrays; truncating or length-leaking before the compare can reintroduce a channel. Historically very old JDKs even short-circuited on length — modern ones fold length into the constant-time result. - Don't hand-roll your own "constant-time" loop unless you understand JIT/branch-prediction effects; prefer the library method.
- Exactly how does an attacker exploit Arrays.equals here?Because it returns at the first mismatching byte, response time grows with the number of correct leading bytes. The attacker varies one byte at a time, watches for the timing increase, locks in the correct value, and walks through the secret byte by byte, reducing an exponential brute force to linear.
- Is MessageDigest.isEqual needed when verifying a public file's SHA-256?Not strictly — the expected hash is public, so a timing leak reveals nothing secret. Arrays.equals is acceptable there. But isEqual is a safe, harmless default, so using it everywhere avoids accidentally short-circuiting a secret-dependent compare.
saying these in an interview costs you the question
- Claiming Arrays.equals is fine for all digest comparisons
- Using == to compare two byte[] (compares references)
- Thinking the timing leak matters even for public integrity hashes — it's about secret-dependent comparisons
- Hand-rolling a comparison with an early return and calling it constant-time