CWE-208: Observable Timing Discrepancy - Java
Overview
In Java, timing discrepancies typically come from comparing secrets - password hashes, HMAC digests, API keys, session tokens - with String.equals() or Arrays.equals(). Both compare lengths and then exit at the first difference they find, making them unsuitable for secret comparison. == is usually flagged alongside them, for a different reason: on a String or a byte[] it compares references rather than contents, so it leaks nothing and answers wrongly - new String("abcdef") == new String("abcdef") is false on JDK 26 while .equals() is true. A scanner flagging == on a secret has found a correctness bug that also happens to be in the right neighbourhood; replacing it with .equals() fixes the correctness and leaves the timing channel, which is what the rest of this page is about. Use java.security.MessageDigest.isEqual(byte[], byte[]) for any comparison involving a secret; since JDK 6u17 it is documented to run in time dependent only on the length of the arrays, not their content. Spring Security's password encoders already use constant-time comparison internally, so the risk is concentrated in hand-written HMAC/signature verification, webhook signature checks, and custom API key or token validation.
The largest timing discrepancy on a typical Spring application is not in any of those comparisons, though - it is a login that resolves the username before hashing anything. See Spring Security below, and check for it first when triaging a finding on an authentication endpoint.
Common Vulnerable Patterns
Manual API Token Comparison
// VULNERABLE - String.equals() is not constant-time: it compares lengths,
// then stops at the first difference
public boolean verifyApiKey(String providedKey, String storedKey) {
return providedKey.equals(storedKey);
}
// Attack: submit many candidate keys of the right length, measure response
// latency per attempt
// Result: a candidate that matches further into the key takes marginally longer
// to reject, and one of the wrong length is rejected faster still
Why this is vulnerable: String.equals() compares the two lengths and then walks the characters, stopping at the first difference, so both the length and the position of the first difference are reflected in how long the call takes. How much they leak is worth measuring rather than assuming: on JDK 26 with a 64-character key compared in-process, a mismatch in the first character took 1.95 ns and one in the last 2.34 ns, with positions 0, 3 and 12 identical to the resolution of System.nanoTime(), because String.equals is a JIT intrinsic that compares several bytes per instruction. The per-character recovery this is usually described as does not follow from those numbers, but the length signal is real, the intrinsic is not a specification, and the replacement below costs nothing.
Webhook Signature Verification with Arrays.equals
// VULNERABLE - Arrays.equals() is not constant-time
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.HexFormat;
public boolean verifyWebhookSignature(byte[] payload, String signatureHeader, String secret) throws Exception {
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
byte[] computed = mac.doFinal(payload);
byte[] provided = HexFormat.of().parseHex(signatureHeader);
return Arrays.equals(computed, provided); // DANGEROUS!
}
Why this is vulnerable: Arrays.equals() compares lengths first and then returns on the first mismatched element, so an attacker who can resend a payload with a guessed signature learns something about where the guess went wrong. On a 32-byte digest the something is small - measured on JDK 26, a first-byte mismatch took 2.15 ns and a last-byte mismatch 2.54 ns, because Arrays.equals is intrinsified to a vector comparison. Treat that as a reason to fix it cheaply rather than as grounds to dismiss it: the intrinsic is an implementation choice, not a guaranteed behaviour.
This example has a second problem that has nothing to do with timing, and it is the one that will page you: HexFormat.parseHex throws IllegalArgumentException on a signature header of odd length and NumberFormatException on one containing a non-hex character. That input is entirely under the attacker's control, so a forged signature returns 500 where a well-formed forgery returns 401 - a difference visible without a stopwatch.
Secure Patterns
MessageDigest.isEqual
// SECURE - constant-time comparison regardless of where a mismatch occurs
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.HexFormat;
private static final int SHA256_HEX_LENGTH = 64;
public boolean verifyApiKey(String providedKey, String storedKey) throws Exception {
// Hash both sides so isEqual() always walks 32 bytes, whatever was submitted.
MessageDigest sha256 = MessageDigest.getInstance("SHA-256");
byte[] provided = sha256.digest(providedKey.getBytes(StandardCharsets.UTF_8));
byte[] expected = sha256.digest(storedKey.getBytes(StandardCharsets.UTF_8));
return MessageDigest.isEqual(provided, expected);
}
public boolean verifyWebhookSignature(byte[] payload, String signatureHeader, String secret) throws Exception {
// The header is attacker-controlled and HexFormat has no non-throwing
// parse, so the decode needs a length check and a catch of its own.
if (signatureHeader == null || signatureHeader.length() != SHA256_HEX_LENGTH) {
return false;
}
byte[] provided;
try {
provided = HexFormat.of().parseHex(signatureHeader);
} catch (IllegalArgumentException e) { // NumberFormatException extends this
return false;
}
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
byte[] computed = mac.doFinal(payload);
return MessageDigest.isEqual(computed, provided);
}
Why this works: MessageDigest.isEqual() runs its loop to completion whatever the two arrays contain, so its running time depends on their length and never on how much of the content matched - no early exit for an attacker's timing measurement to key off.
Two details of isEqual are worth knowing, because both differ from the equivalent helpers in other languages and neither matches what its callers usually assume. It does not return early on unequal-length inputs: reading the JDK 26 source, the length difference is folded into the same accumulator as the byte differences (result |= lenA - lenB) and the loop runs to completion, so isEqual(new byte[32], new byte[8]) returns false having done 32 iterations rather than none. And the loop runs over the length of the first argument, so it is the first argument that decides how long the call takes. Passing the submitted value first, as above, means the duration follows attacker-supplied data they already know; passing the secret first would make every call announce the secret's length. Hashing both sides makes the question moot, which is why the API key example does it.
Decoding the signature safely is the other half. Verified against a real HMAC on JDK 26: a genuine signature returns true in upper or lower case, a signature with one hex digit changed returns false, and "zzzz", "abc", "", "de ad", null, 64 z characters and a genuine signature with two characters appended all return false rather than throwing. Catching a decode failure here is not the anti-pattern of using an exception as the comparison result - the comparison has not happened yet, and Java offers no tryParseHex.
Framework-Specific Guidance
Spring Security
// SECURE - let the encoder compare, and hash on both branches so the
// response time does not say which usernames exist
@Bean
PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder(12);
}
// A real BCrypt hash carrying the SAME cost factor as the stored hashes -
// matches() reads the cost out of the hash it is given, not out of the encoder.
private static final String DUMMY_HASH =
"$2a$12$usesomesillystringfore7hnbRJHxXVLeakoG8K30oukPsA.ztMG";
public boolean verifyPassword(String username, String rawPassword) {
User user = userRepository.findByUsername(username); // null when absent
String storedHash = (user == null) ? DUMMY_HASH : user.getPasswordHash();
boolean matched = passwordEncoder.matches(rawPassword, storedHash);
return user != null && matched;
}
Why this works: BCryptPasswordEncoder and Spring Security's other adaptive encoders - Argon2PasswordEncoder, SCryptPasswordEncoder, Pbkdf2PasswordEncoder - compare the computed hash against the stored one in constant time as part of matches(), so the comparison itself is not where a Spring login leaks. "Every PasswordEncoder" would be too strong: NoOpPasswordEncoder, still reachable through the {noop} prefix in DelegatingPasswordEncoder and used in more tutorials than anyone would like, implements matches() as rawPassword.toString().equals(encodedPassword) - confirmed in the 6.5.6 bytecode, a plain String.equals on the password itself. It is deprecated and stores passwords in the clear, so a finding against it is not really about timing; the point here is only that the guarantee comes from the encoder you configured, not from the interface. What leaks is the lookup: returning as soon as the repository comes back empty skips the BCrypt work entirely. Measured on spring-security-crypto 6.5.6, matches() against a real stored hash took 57.6 ms at the default strength of 10 and 229.8 ms at strength 12 - the whole cost of a login either way - so a branch that skips it answers in microseconds and publishes which usernames exist. Verified with the code above at strength 12: an unknown username, a known username with a wrong password and a known username with the right password came in at 229.7, 229.8 and 229.8 ms, and the correct password still authenticated.
The dummy has to be a genuine hash at the cost factor the stored hashes carry, and both halves of that matter. BCryptPasswordEncoder checks the format before doing any work, so matches(rawPassword, ""), matches(rawPassword, null) and matches(rawPassword, "not-a-hash") all return false in 0.11-0.16 ms, roughly 400x faster than the real path - and log a warning on every failed login, which is the tell if this has already happened to you. Getting the cost wrong fails in the other direction and is easier to miss, because matches() takes the cost from the hash string it is handed rather than from the encoder: pair the widely-copied $2a$12$... dummy above with a default new BCryptPasswordEncoder() and the unknown-username path costs 229.6 ms against 57.6 ms for a real user, an inverted oracle that reads just as clearly as the one it replaced. That also means a strength upgrade does not reach rows hashed before it, so the dummy has to match the hashes actually in the table. CWE-287 has the full AuthenticationProvider version of this.
Custom Filter or Interceptor for API Key Checks
// SECURE - API key check in a servlet filter or Spring interceptor
import java.security.MessageDigest;
import java.nio.charset.StandardCharsets;
public class ApiKeyAuthenticator {
private final byte[] expectedKeyDigest;
public ApiKeyAuthenticator(String configuredKey) throws Exception {
this.expectedKeyDigest = sha256(configuredKey);
}
public boolean authenticate(String providedKey) throws Exception {
if (providedKey == null) {
return false;
}
return MessageDigest.isEqual(sha256(providedKey), expectedKeyDigest);
}
private static byte[] sha256(String value) throws Exception {
return MessageDigest.getInstance("SHA-256")
.digest(value.getBytes(StandardCharsets.UTF_8));
}
}
Why this works: Custom filters and interceptors are the code most likely to hand-roll a comparison outside a framework's built-in authentication path; routing the check through MessageDigest.isEqual() closes the same timing channel a hand-written .equals() check would leave open. Hashing the configured key once in the constructor and each submitted key on arrival keeps both operands at 32 bytes, so the loop inside isEqual runs the same number of iterations whatever was submitted - which matters here because that loop is bounded by its first argument.
Considerations
Confirm the compared value is actually a secret. This weakness is about
comparisons an attacker can time their way through, which means the value has
to be one they are trying to guess: a password hash, an HMAC digest, an API
key, a session token, a signature. == on a username, a public identifier or a
feature flag is not this finding, however much it looks like the flagged
pattern. Record those as false positives with the reason.
Check whether the library already did it. Password verification helpers in the major frameworks compare in constant time internally, so a comparison of the boolean result they return is not a timing leak and does not need changing. The finding is about your own comparison of raw secret bytes.
If you authenticate through DaoAuthenticationProvider, the leak is not the
one above. Spring already handles the unknown-username case: it hashes a
placeholder when the UserDetailsService finds nothing, so an unknown name
costs the same as a real one - measured on spring-security 6.5.6, 57.6 ms
against 58.8 ms for an enabled user with a wrong password. What it does not
equalize is account state. AbstractUserDetailsAuthenticationProvider runs its
pre-authentication checks before additionalAuthenticationChecks, which is
where the password is verified, so a disabled or locked account returns without
hashing at all: both measured at 0.01 ms, roughly 5,800x faster, and a
disabled account answers that way even when the submitted password is correct.
The endpoint therefore still separates "no such user" from "this user exists and
is disabled or locked", and every account awaiting activation is in that state.
Moving the state checks after the password check closes it, using the
setters the provider already exposes. Install a no-op
setPreAuthenticationChecks and put the real checks in
setPostAuthenticationChecks, which runs after additionalAuthenticationChecks:
DaoAuthenticationProvider provider = new DaoAuthenticationProvider(passwordEncoder);
provider.setUserDetailsService(userDetailsService);
provider.setPreAuthenticationChecks(user -> { }); // was: locked/enabled/expired
provider.setPostAuthenticationChecks(new AccountStatusUserDetailsChecker());
Verified on spring-security 6.5.6: unknown, enabled-with-wrong-password,
disabled, locked and disabled-with-the-right-password all came in between 57.56
and 57.76 ms, against 0.01 ms for the disabled and locked cases before the
change - and the outcomes are unchanged, with DisabledException,
LockedException and BadCredentialsException each still raised for the same
input. AccountStatusUserDetailsChecker is the class that performs all four
account-state checks; the stock post-check tests only isCredentialsNonExpired,
so passing it instead would silently drop the other three.
Two costs to weigh, and they are the same two the C# page describes. Every
request naming a locked account now pays a full BCrypt, which is part of what
locking exists to avoid, so rate-limit by source address ahead of
authentication. And timing is only one channel: DisabledException and
LockedException are still distinct types, so an AuthenticationFailureHandler
that renders them differently gives back through the response what the
reordering just closed. ASP.NET Core Identity has the same shape and
its page carries the equivalent measurement; Django's
ModelBackend is the counter-example, running the hash first and checking
is_active afterwards.
Decide whether leaking the length matters. Unlike .NET's FixedTimeEquals
and Node's timingSafeEqual, MessageDigest.isEqual neither requires equal
lengths nor rejects unequal ones early, so no length pre-check is needed and
writing one would add a leak the helper does not have. What it does have is a
loop bounded by the first argument, so pass the submitted value first and the
secret second. For fixed-size values (a SHA-256 digest, a signature) the length
is public anyway. For variable-length secrets such as API keys, hash both sides
first and compare the digests, so the comparison is fixed-width whichever way
round the arguments go.
Testing
- A correct API key, token and webhook signature still authenticate. Without that case, a fix that rejects everything passes the suite as cleanly as a working one.
- A signature header of
"zzzz","abc","","de ad",nulland a genuine signature with two characters appended each return401, not500. A500meansparseHexis still reached with attacker-controlled input, and it is distinguishable from a401without any timing measurement at all. - Time one login per account state, not three. Unknown username, enabled user with a wrong password, enabled user with the right password, disabled user and locked user - and, if you authenticate through
DaoAuthenticationProvider, a disabled user with the correct password, which is the case that answered in 0.01 ms before the pre/post check swap. All the failing cases should land within noise of each other; measured on spring-security 6.5.6 after the swap they fall between 57.56 and 57.76 ms. - The outcomes have to survive the reordering. Assert that a disabled account still raises
DisabledException, a locked oneLockedException, a wrong passwordBadCredentialsException, and that a correct password on a good account still authenticates. Moving the checks intosetPostAuthenticationCheckswith the stock checker rather thanAccountStatusUserDetailsCheckersilently drops three of the four. - The application log contains no
Empty encoded passwordorEncoded password does not look like BCryptwarning after a failed login. Either one means the dummy hash is not a valid BCrypt string, so the branch meant to equalize the timing is returning without hashing. - Search the codebase for other
.equals(,Arrays.equals(and==call sites that compare against a hash, token, key or secret field - the reported line is a sample, not the population.
Common Pitfalls
- Using
MessageDigest.isEqual()only on the final comparison, but looking up which stored secret to compare against with a non-constant-time string comparison first - the lookup step itself must not branch on secret content, or the fix only closes half the channel. - Calling
String.equals()on the hex- or Base64-encoded representation of a digest instead of the raw bytes, on the theory that encoding avoids the issue - encoding does not change the comparison semantics;String.equals()on the encoded form is exactly as vulnerable as on the raw bytes. - Assuming Spring Security's
PasswordEncodercovers custom token or API key checks too - it protects password verification specifically; any additional hand-written HMAC, signature, or key comparison still needs its ownMessageDigest.isEqual()call.