bcrypt, Timing Attacks, and the DelegatingPasswordEncoder Migration Path
bcrypt, Timing Attacks, and the DelegatingPasswordEncoder Migration Path
The Assumption
bcrypt is the safe default. Every framework ships it, every tutorial recommends it, and it has decades of production use. The assumption: if you use bcrypt, password storage is solved.
Two problems. First, bcrypt silently truncates input at 72 bytes. Second, bcrypt’s age means many deployments run with work factors calibrated for hardware that no longer represents attacker capability.
A third problem is less obvious: teams that adopted bcrypt years ago never increased the work factor. Their databases contain hashes at work factor 10 (the Spring Security default), which is now dangerously weak against dedicated GPU hardware. There is no mechanism to upgrade existing hashes without the user logging in and providing their password again.
The Attack
The 72-byte truncation attack. bcrypt processes at most 72 bytes of input. Any characters beyond position 72 are ignored. Two passwords that share the same first 72 bytes but differ after that position produce identical hashes.
This matters for two scenarios:
-
A user creates a very long passphrase. Characters beyond position 72 provide zero additional security. The effective entropy is capped at whatever the first 72 bytes contain.
-
An attacker who knows the first 72 bytes of a password (through partial leakage or social engineering) can append anything to those bytes and authenticate successfully.
The pre-hashing workaround vulnerability. A common mitigation is to SHA-256 hash the password before passing it to bcrypt, reducing any-length input to 32 bytes. This introduces a null-byte vulnerability: if the SHA-256 output contains a 0x00 byte (probability ~12% per byte, cumulative probability significant), bcrypt treats the null byte as a string terminator in C implementations. The effective input is truncated at the null byte position, potentially reducing the hash to a few bytes of input.
// VULNERABLE: Pre-hashing with null-byte vulnerability
String preHashed = Base64.getEncoder().encodeToString(
MessageDigest.getInstance("SHA-256").digest(password.getBytes(UTF_8))
);
// If preHashed contains characters that map to 0x00 in the bcrypt C implementation,
// the input is silently truncated.
return bcryptEncoder.encode(preHashed);
The safe workaround is to Base64-encode the SHA-256 output (which guarantees no null bytes) and verify the bcrypt implementation handles the full Base64 string. Spring Security’s BCryptPasswordEncoder operates on the raw string bytes, not C strings, so the null-byte issue does not apply to the Java implementation. But if you ever migrate hashes to a system using a C-based bcrypt library, the vulnerability reappears.
The Spec or Mechanism
bcrypt (Blowfish-crypt) was designed by Niels Provos and David Mazières. It uses the Blowfish cipher’s expensive key setup phase, repeated 2^(work_factor) times, to derive the hash. The work factor is a single integer: each increment doubles the computation time.
Work factor progression on modern server hardware (single core):
- Work factor 10: ~100ms (Spring Security default)
- Work factor 12: ~400ms
- Work factor 14: ~1600ms
- Work factor 16: ~6400ms
On attacker GPU hardware (RTX 4090):
- Work factor 10: ~5,000 hashes/second
- Work factor 12: ~1,250 hashes/second
- Work factor 14: ~312 hashes/second
The timing attack on password comparison is a separate concern. When verifying a password, the implementation must compare the computed hash with the stored hash in constant time. A naive implementation using String.equals() returns on the first mismatched byte, leaking information about how many bytes matched through response time variation.
An attacker who can measure authentication response time with millisecond precision can determine hash prefixes by statistical analysis over many requests. After millions of requests with varying passwords, they can reconstruct enough of the hash to narrow the search space significantly.
Spring Security’s BCryptPasswordEncoder.matches() delegates to BCrypt.checkpw(), which calls BCrypt.equalsNoEarlyReturn(). This method iterates over all bytes regardless of mismatch position, using XOR accumulation:
static boolean equalsNoEarlyReturn(String a, String b) {
if (a.length() != b.length()) {
return false; // Length difference is not timing-sensitive
}
int result = 0;
for (int i = 0; i < a.length(); i++) {
result |= a.charAt(i) ^ b.charAt(i);
}
return result == 0;
}
The length check leaks that hashes differ in length, but bcrypt always produces fixed-length output, so this branch is never taken for valid hashes.
The Implementation
// VULNERABLE: BCrypt with default work factor and no migration path
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder(); // Work factor 10
}
// No upgrade path. All hashes stay at work factor 10 forever.
// 5,000 hashes/second on attacker GPU hardware.
// HARDENED: BCrypt with explicit work factor and upgrade-on-login
@Bean
public PasswordEncoder passwordEncoder() {
String defaultId = "bcrypt";
Map<String, PasswordEncoder> encoders = Map.of(
"bcrypt", new BCryptPasswordEncoder(14), // ~1600ms, 312 GPU h/s
"bcrypt10", new BCryptPasswordEncoder(10) // Legacy hashes
);
DelegatingPasswordEncoder delegating =
new DelegatingPasswordEncoder(defaultId, encoders);
delegating.setDefaultPasswordEncoderForMatches(
new BCryptPasswordEncoder(10) // Handles legacy hashes without prefix
);
return delegating;
}
Upgrade-on-Login Pattern
The DelegatingPasswordEncoder handles validation of old hashes, but it does not automatically re-hash on login. You need an AuthenticationSuccessHandler or a custom UserDetailsService wrapper to trigger re-hashing:
@Component
public class PasswordUpgradeService {
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
public PasswordUpgradeService(UserRepository userRepository,
PasswordEncoder passwordEncoder) {
this.userRepository = userRepository;
this.passwordEncoder = passwordEncoder;
}
@EventListener
public void onAuthenticationSuccess(AuthenticationSuccessEvent event) {
Authentication auth = event.getAuthentication();
if (auth instanceof UsernamePasswordAuthenticationToken token) {
String username = token.getName();
Object credentials = token.getCredentials();
if (credentials instanceof String rawPassword) {
upgradeIfNeeded(username, rawPassword);
}
}
}
private void upgradeIfNeeded(String username, String rawPassword) {
userRepository.findByUsername(username).ifPresent(user -> {
if (passwordEncoder.upgradeEncoding(user.getPasswordHash())) {
String newHash = passwordEncoder.encode(rawPassword);
user.setPasswordHash(newHash);
userRepository.save(user);
}
});
}
}
The upgradeEncoding() method on DelegatingPasswordEncoder returns true if the hash uses a different encoder than the current default. When it returns true, we re-hash with the current default (Argon2id or higher bcrypt work factor) and save. The user never notices. Over time, all active users migrate to the stronger hash. Dormant accounts retain old hashes, which is acceptable because dormant accounts are typically locked or subject to forced password reset.
Migration From bcrypt to Argon2id
The full migration uses the same pattern with Argon2id as the default:
@Bean
public PasswordEncoder passwordEncoder() {
String defaultId = "argon2id";
Map<String, PasswordEncoder> encoders = Map.of(
"argon2id", new Argon2PasswordEncoder(16, 32, 1, 65536, 3),
"bcrypt", new BCryptPasswordEncoder(14),
"bcrypt10", new BCryptPasswordEncoder(10)
);
DelegatingPasswordEncoder delegating =
new DelegatingPasswordEncoder(defaultId, encoders);
// Handle legacy hashes without {id} prefix (assumed bcrypt10)
delegating.setDefaultPasswordEncoderForMatches(new BCryptPasswordEncoder(10));
return delegating;
}
Stored hash format progression:
- Legacy (no prefix):
$2a$10$...→ matches viasetDefaultPasswordEncoderForMatches - After first upgrade:
{bcrypt}$2a$14$...→ matches via “bcrypt” encoder - After second upgrade:
{argon2id}$argon2id$v=19$m=65536,t=3,p=1$...→ matches via “argon2id” encoder
The Verification
@SpringBootTest
class PasswordMigrationTest {
@Autowired
private PasswordEncoder passwordEncoder;
@Autowired
private UserRepository userRepository;
@Autowired
private PasswordUpgradeService upgradeService;
@Test
void legacyBcryptHashValidatesAndUpgrades() {
// Simulate legacy hash (no prefix, work factor 10)
BCryptPasswordEncoder legacyEncoder = new BCryptPasswordEncoder(10);
String legacyHash = legacyEncoder.encode("user-password");
// Create user with legacy hash
User user = new User("legacy-user", legacyHash);
userRepository.save(user);
// Verify legacy hash still validates
assertThat(passwordEncoder.matches("user-password", legacyHash)).isTrue();
// Verify upgrade is needed
assertThat(passwordEncoder.upgradeEncoding(legacyHash)).isTrue();
// Simulate login success event triggering upgrade
upgradeService.onAuthenticationSuccess(
new AuthenticationSuccessEvent(
new UsernamePasswordAuthenticationToken(
"legacy-user", "user-password")));
// Verify hash was upgraded to Argon2id
User updated = userRepository.findByUsername("legacy-user").orElseThrow();
assertThat(updated.getPasswordHash()).startsWith("{argon2id}");
assertThat(passwordEncoder.matches("user-password",
updated.getPasswordHash())).isTrue();
}
@Test
void bcrypt72ByteTruncation() {
BCryptPasswordEncoder bcrypt = new BCryptPasswordEncoder(10);
String longPassword = "a".repeat(72) + "DIFFERENT_ENDING";
String shortPassword = "a".repeat(72) + "OTHER_ENDING";
String hash = bcrypt.encode(longPassword);
// Both passwords match because bcrypt truncates at 72 bytes
assertThat(bcrypt.matches(shortPassword, hash)).isTrue();
// Argon2id does NOT truncate
Argon2PasswordEncoder argon2 = new Argon2PasswordEncoder(16, 32, 1, 65536, 3);
String argonHash = argon2.encode(longPassword);
assertThat(argon2.matches(shortPassword, argonHash)).isFalse();
}
}
The second test proves the 72-byte truncation vulnerability in bcrypt and demonstrates that Argon2id does not share this limitation. This is a concrete, testable reason to prefer Argon2id beyond the GPU resistance argument.