Password Encoding: BCrypt, Argon2 and Migrating Legacy Hashes
Why a fast hash is the wrong tool, how the work factor trades login latency for attack cost, and migrating MD5 hashes without forcing a password reset.
Fast Hashes Are the Wrong Tool
Everyone knows not to store plaintext passwords. The less obvious error is storing a fast hash.
SHA-256 is designed to be fast — that's what makes it good for checksums and useless for passwords. A commodity GPU computes billions of SHA-256 hashes per second, so a stolen table of SHA-256 password hashes is a dictionary attack that completes over a weekend. Adding a salt stops precomputed rainbow tables but does nothing about raw speed: the attacker simply works per-user.
Password hashing needs a function that is deliberately slow and expensive to parallelise, with a cost you can increase as hardware improves. That's what BCrypt, Argon2 and scrypt are for.
| Algorithm | Resists | Notes |
|---|---|---|
| BCrypt | CPU brute force | Spring's default. Mature, well-understood. 72-byte input cap |
| Argon2id | CPU and memory-hard | Winner of the Password Hashing Competition; current best practice |
| SCrypt | CPU and memory-hard | Predates Argon2, still sound |
| PBKDF2 | CPU brute force | FIPS-approved; weakest of these against GPUs |
| MD5, SHA-1, SHA-256 | Nothing, for this purpose | Never for passwords |
PasswordEncoder
@Bean
PasswordEncoder passwordEncoder() {
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}That factory method is the right default, for reasons the migration section below makes clear. The interface is small:
public interface PasswordEncoder {
String encode(CharSequence rawPassword);
boolean matches(CharSequence rawPassword, String encodedPassword);
}Registering it, hashes are handled for you — DaoAuthenticationProvider calls matches during login:
@Service
public class RegistrationService {
private final PasswordEncoder encoder;
private final AccountRepository accounts;
public Account register(RegistrationRequest request) {
Account account = new Account(request.email(), encoder.encode(request.password()));
return accounts.save(account);
}
}The raw password must never be logged, stored or echoed — including in exception messages, debug logs, request-body logging, or an audit record of the registration request. A @ControllerAdvice that logs the failing request body will log passwords on a validation failure, which is a surprisingly common way for plaintext credentials to end up in a log aggregator with a long retention period.
The Work Factor
BCrypt's cost parameter is a power of two number of iterations:
new BCryptPasswordEncoder(12); // 2^12 iterationsEach increment doubles the time — to verify and to attack. Spring's default is 10; the usual recommendation now is 10–12, tuned to your hardware.
The calibration is straightforward: measure it, and target roughly 100–250ms per hash on your production hardware.
@Test
void bcryptCostIsCalibrated() {
var encoder = new BCryptPasswordEncoder(12);
long start = System.nanoTime();
encoder.encode("a-representative-password");
long ms = Duration.ofNanos(System.nanoTime() - start).toMillis();
assertThat(ms).isBetween(50L, 500L);
}Why that range is the trade-off:
- Too low — cheap for you, cheap for an attacker with your database.
- Too high — every login costs that much CPU, and a login burst becomes a self-inflicted denial of service. At 1 second per hash, 50 concurrent logins saturate 50 cores.
Password hashing is CPU-intensive by design, which makes your login endpoint an amplification target: an attacker sending junk credentials forces maximum-cost work per request. Rate-limit authentication endpoints per IP and per account, independently of the hashing cost. Without that, a high work factor makes the attack more effective rather than less.
Argon2
@Bean
PasswordEncoder passwordEncoder() {
return new Argon2PasswordEncoder(16, 32, 1, 19 * 1024, 2);
// saltLength, hashLength, parallelism, memoryInKB, iterations
}Argon2id is memory-hard: each hash needs a configured amount of memory (19 MB above), which is what defeats GPU and ASIC attacks — those have many cores but limited memory per core. The OWASP baseline is 19 MiB, 2 iterations, parallelism 1.
Requires BouncyCastle on the classpath. For a new application Argon2id is the better choice; BCrypt remains perfectly defensible and is less to configure.
BCrypt silently truncates input at 72 bytes. Every password sharing the first 72 bytes is equivalent — which matters for passphrase users and for anyone pre-hashing. It's also a trap if you pre-hash with SHA-256 and hex-encode: 64 characters fits, but base64 of SHA-512 does not. Argon2 has no such limit.
Migrating Legacy Hashes
The practical problem: an existing table of MD5 or unsalted SHA-1 hashes. You cannot recover the passwords to re-hash them, and forcing a password reset on every user is a support and retention cost.
DelegatingPasswordEncoder solves this by prefixing each hash with the algorithm that produced it:
{bcrypt}$2a$12$Dwt1BZj6pcyc3Dy1FWZ...
{argon2}$argon2id$v=19$m=19456,t=2,p=1$...
{MD5}5f4dcc3b5aa765d61d8327deb882cf99
{noop}plaintext
@Bean
PasswordEncoder passwordEncoder() {
String idForEncode = "bcrypt";
Map<String, PasswordEncoder> encoders = Map.of(
"bcrypt", new BCryptPasswordEncoder(12),
"argon2", new Argon2PasswordEncoder(16, 32, 1, 19 * 1024, 2),
"MD5", new MessageDigestPasswordEncoder("MD5")); // verify only
return new DelegatingPasswordEncoder(idForEncode, encoders);
}New passwords are hashed with bcrypt; existing {MD5} hashes still verify. One encoder bean, several formats.
Upgrade on login
The important half: re-hash each password the next time its owner logs in successfully, when you briefly have the plaintext.
@Component
public class PasswordUpgradeService implements UserDetailsPasswordService {
private final AccountRepository accounts;
private final PasswordEncoder encoder;
@Override
public UserDetails updatePassword(UserDetails user, String newPassword) {
accounts.findByEmailIgnoreCase(user.getUsername()).ifPresent(account -> {
account.setPasswordHash(newPassword); // already encoded by Spring
accounts.save(account);
});
return User.withUserDetails(user).password(newPassword).build();
}
}Spring Security calls this automatically when PasswordEncoder.upgradeEncoding(...) reports the stored hash uses an older scheme. Users migrate transparently as they log in — no reset, no notification.
Then remove the legacy encoder. After a migration window — say 90 days, by which point active users have logged in — drop MD5 from the map. Remaining accounts can no longer log in and must reset, which is the correct outcome: those hashes were the liability you set out to remove. Track the count of un-migrated rows so you know when the window has done its work.
A hash without a prefix throws IllegalArgumentException: There is no PasswordEncoder mapped for the id "null" — the most common error when adopting DelegatingPasswordEncoder. Legacy rows need their prefix added:
update accounts set password_hash = '{MD5}' || password_hash where password_hash not like '{%';Run it as a Flyway migration, and verify the existing format first — a column already holding $2a$... BCrypt hashes needs {bcrypt}, not {MD5}.
The Rest of the Password Story
Hashing is necessary and not sufficient. Current guidance — NIST SP 800-63B and OWASP — differs from long-standing habit in ways worth knowing:
Length over composition. Require a minimum of 8 (ideally 12) characters and allow at least 64. Drop mandatory character-class rules: they push users toward Passw0rd! and measurably reduce entropy.
Check against known-breached passwords. A password appearing in a breach corpus is unsafe regardless of its shape. The Have I Been Pwned range API lets you check without sending the password — you send the first five characters of its SHA-1 hash and match locally.
Don't expire passwords on a schedule. Forced rotation produces predictable increments (Spring2026! → Summer2026!). Rotate on evidence of compromise instead.
Rate-limit and lock. Progressive delays or temporary lockout after repeated failures, per account and per IP — per-account alone invites a lockout denial of service against a known user.
Support a password manager. Allow paste, allow long passwords, allow the full character set.
Check yourself
A team migrates from MD5 to BCrypt by adding DelegatingPasswordEncoder with both encoders, prefixing existing hashes with {MD5}. Six months later the MD5 encoder is still configured and 40% of hashes are still {MD5}. What is the state of the migration?
The Mental Model, Restated
- A fast hash is the wrong tool. SHA-256 is billions per second on a GPU.
- BCrypt (cost 10–12) or Argon2id. Memory-hardness is why Argon2 is preferred for new work.
- Calibrate to ~100–250ms on production hardware, then rate-limit login regardless.
- BCrypt truncates at 72 bytes.
DelegatingPasswordEncoderstores the algorithm as a prefix, so several formats coexist.- Upgrade on login via
UserDetailsPasswordService, then remove the legacy encoder after a window. - Length over composition, check breach corpora, don't expire on a schedule.
What's Next
Authentication establishes identity; it says nothing about what that identity may do. The next guide covers authorisation — URL rules versus method security, roles versus authorities, role hierarchies, and the expression-based rules that let a decision depend on the data being accessed rather than just the caller.