You must migrate a production user base from {bcrypt} to {argon2} with zero downtime and no forced password resets. How do you design this with Spring Security's password APIs?
answer
- verify-old / encode-new via DelegatingPasswordEncoder
- idForEncode=argon2, keep bcrypt in map
- UserDetailsPasswordService persists re-hash
- lazy: migrates on login only
- dormant-account cutoff / forced reset
basics
~20 sUse a DelegatingPasswordEncoder whose default encode id is argon2 but whose map still contains bcrypt for verification. Provide a UserDetailsPasswordService so that upgradeEncoding re-hashes each user to {argon2} on their next successful login. Old logins keep working throughout.
solid answer
~40 sThe DelegatingPasswordEncoder already stores an {id} prefix per hash, which makes this a gradual, verify-old/encode-new migration rather than a big bang. Configure the encoder with idForEncode = argon2 and a map containing both {argon2} and {bcrypt}, so existing {bcrypt} hashes still verify while new encodes emit {argon2}. Implement UserDetailsPasswordService.updatePassword to persist the re-hash. On each successful login, DaoAuthenticationProvider calls upgradeEncoding(storedHash); since the stored id ({bcrypt}) differs from the default ({argon2}), it returns true, the plaintext (in hand from login) is re-encoded to {argon2}, and updatePassword persists it. Coverage grows organically as users authenticate. For dormant accounts that never log in, set a policy: after a cutoff date, force password reset or expire remaining {bcrypt} credentials. Add Argon2's BouncyCastle dependency, benchmark memory/time/parallelism, and monitor the {bcrypt}-remaining count.
code
java · 19 lines@Configuration
class PasswordConfig {
@Bean
PasswordEncoder passwordEncoder() {
Map<String, PasswordEncoder> encoders = new HashMap<>();
encoders.put("argon2", Argon2PasswordEncoder.defaultsForSpringSecurity_v5_8());
encoders.put("bcrypt", new BCryptPasswordEncoder());
return new DelegatingPasswordEncoder("argon2", encoders); // encode->argon2, verify both
}
@Bean
UserDetailsPasswordService passwordUpgrader(UserRepository repo) {
return (user, newHash) -> {
repo.updatePasswordHash(user.getUsername(), newHash);
return User.withUserDetails(user).password(newHash).build();
};
}
}go deeper
Recognize that you keep the old encoder for verification and encode new ones differently.
Wire DelegatingPasswordEncoder + UserDetailsPasswordService and explain login-time upgrade.
Add the dormant-account cutoff, idempotency, and dependency/tuning concerns.
Own the end-to-end rollout: metrics on remaining hashes, forced-reset policy, DoS/capacity, auditing, and rollback safety.
## Why this is a design question, not a config toggle Passwords are one-way hashes: you **cannot** convert `{bcrypt}` hashes into `{argon2}` in bulk, because you don't have the plaintext. So any real migration must be **lazy** — it can only re-hash a user's password at the moment they log in and hand you the plaintext. Spring Security is built exactly for this via the `{id}` prefix + `upgradeEncoding` + `UserDetailsPasswordService`. ## Step 1 — Encoder that verifies old and encodes new ```java @Bean PasswordEncoder passwordEncoder() { String idForEncode = "argon2"; // new default Map<String, PasswordEncoder> encoders = new HashMap<>(); encoders.put("argon2", Argon2PasswordEncoder.defaultsForSpringSecurity_v5_8()); encoders.put("bcrypt", new BCryptPasswordEncoder()); // keep for verification return new DelegatingPasswordEncoder(idForEncode, encoders); } ``` Now `encode()` emits `{argon2}...`, while `matches()` still dispatches `{bcrypt}...` hashes to the BCrypt encoder. No existing login breaks. ## Step 2 — Make upgrades persist ```java @Service class ArgonMigratingPasswordService implements UserDetailsPasswordService { private final UserRepository repo; ArgonMigratingPasswordService(UserRepository repo) { this.repo = repo; } @Override public UserDetails updatePassword(UserDetails user, String newHash) { repo.updatePasswordHash(user.getUsername(), newHash); // now {argon2}... return User.withUserDetails(user).password(newHash).build(); } } ``` `DaoAuthenticationProvider` flow per login: 1. `matches(raw, "{bcrypt}...")` → success. 2. `upgradeEncoding("{bcrypt}...")` → `true` because the stored id ≠ `idForEncode` (`argon2`). 3. `encode(raw)` → `{argon2}...`; `updatePassword` persists it. User is now on Argon2, transparently, with no reset and no downtime. ## Step 3 — Handle the long tail (dormant accounts) Lazy migration only reaches users who log in. Design an explicit policy for the rest: - Track the count of remaining `{bcrypt}` hashes as a migration metric. - After a cutoff date, expire or force-reset accounts still on `{bcrypt}` (e.g., set an account flag that routes them through a reset flow). Only then can you safely drop the `bcrypt` entry from the map. ## Step 4 — Operational concerns - **Dependency**: `Argon2PasswordEncoder` needs **BouncyCastle** on the classpath. - **Parameter tuning**: Argon2 has three knobs — memory (KB), iterations (time), and parallelism. Benchmark on production-class hardware; memory-hardness is Argon2's main advantage over BCrypt but also means each hash consumes real RAM, which affects capacity planning and DoS surface. Use `Argon2id`. - **Rate limiting**: memory/CPU-heavy hashing amplifies login-endpoint DoS; keep throttling in place. - **Concurrency/idempotency**: two simultaneous logins may both attempt an update; the write must be safe to repeat. - **Auditing/CDC**: a hash column changing on login can trip change-data-capture or trigger alerts — coordinate with those systems. - **Rollback**: keep the `bcrypt` encoder in the map for the entire transition; removing it prematurely breaks any not-yet-migrated user. - **Testing**: add tests asserting that a stored `{bcrypt}` hash both authenticates and gets rewritten to `{argon2}` after login. ## Alternative framing to mention If you were *raising BCrypt cost* instead of switching algorithms, the same machinery applies — you'd keep `idForEncode = bcrypt` at the new strength, and `BCryptPasswordEncoder.upgradeEncoding` returns true for the lower-cost stored hashes. The `{id}`-based approach shines specifically when the algorithm itself changes.
- After a year, 3% of users are still on {bcrypt} because they never logged in. Can you drop the bcrypt encoder from the map?Not yet — removing it would make those 3% unable to authenticate (matches() would throw for {bcrypt}). First route them through a forced password reset or account-expiry flow, confirm zero {bcrypt} hashes remain, then remove the encoder.
- How is migrating {bcrypt}->{argon2} different from just raising BCrypt strength from 10 to 12?Raising strength keeps idForEncode=bcrypt; BCryptPasswordEncoder.upgradeEncoding returns true when the stored cost is below the new strength. Switching algorithms changes idForEncode to argon2, and DelegatingPasswordEncoder triggers the upgrade because the stored {id} differs from the default. Both reuse the same login-time rehash mechanism.
saying these in an interview costs you the question
- Proposing a batch job that 're-hashes all passwords' (impossible without plaintext)
- Removing the bcrypt encoder before all users migrate
- Forgetting UserDetailsPasswordService so nothing ever persists
- Ignoring dormant accounts / no cutoff policy
- Not tuning Argon2 memory or missing the BouncyCastle dependency