05-spring-security

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.

October 9, 2026
spring-securitypasswordsbcryptargon2PasswordEncoderhashingsecurity

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.

AlgorithmResistsNotes
BCryptCPU brute forceSpring's default. Mature, well-understood. 72-byte input cap
Argon2idCPU and memory-hardWinner of the Password Hashing Competition; current best practice
SCryptCPU and memory-hardPredates Argon2, still sound
PBKDF2CPU brute forceFIPS-approved; weakest of these against GPUs
MD5, SHA-1, SHA-256Nothing, for this purposeNever for passwords

PasswordEncoder

java
@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:

java
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:

java
@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:

java
new BCryptPasswordEncoder(12);     // 2^12 iterations

Each 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.

java
@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

java
@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:

text
{bcrypt}$2a$12$Dwt1BZj6pcyc3Dy1FWZ...
{argon2}$argon2id$v=19$m=19456,t=2,p=1$...
{MD5}5f4dcc3b5aa765d61d8327deb882cf99
{noop}plaintext
java
@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.

java
@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:

sql
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

  1. A fast hash is the wrong tool. SHA-256 is billions per second on a GPU.
  2. BCrypt (cost 10–12) or Argon2id. Memory-hardness is why Argon2 is preferred for new work.
  3. Calibrate to ~100–250ms on production hardware, then rate-limit login regardless.
  4. BCrypt truncates at 72 bytes.
  5. DelegatingPasswordEncoder stores the algorithm as a prefix, so several formats coexist.
  6. Upgrade on login via UserDetailsPasswordService, then remove the legacy encoder after a window.
  7. 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.