Spring Boot Tutorial
Password Encoding and Hashing
A password database will eventually leak — through a backup, a SQL injection bug, or a misconfigured server. Password hashing is what decides whether that leak is an embarrassment or a disaster. Spring Security's PasswordEncoder abstraction makes the safe choice the easy one.
This lesson explains why passwords are hashed rather than encrypted, which algorithms Spring Security supports, why DelegatingPasswordEncoder with its {id} prefix is the recommended default, and how to upgrade old hashes to stronger ones automatically as users log in.
Hashing, Not Encryption
Encryption is reversible: whoever has the key can recover the original password. Hashing is one-way: you can check whether a password matches the hash, but you cannot get the password back. Applications never need the original password, only a yes/no answer to "does this match?", so hashing is always the right tool.
Fast hashes such as MD5 or SHA-256 are the wrong kind of hash for passwords. Attackers with a GPU can try billions of guesses per second against them. Password hashing algorithms are deliberately slow and add a random salt per password, so identical passwords produce different hashes and precomputed rainbow tables are useless.
Algorithms Supported by Spring Security
Spring Security provides encoders for the modern adaptive algorithms. Each has a work factor you can raise over time as hardware gets faster.
BCryptPasswordEncoder— the long-standing default. Strength (log rounds) defaults to 10; aim for a hash that takes roughly 250ms–1s on your servers. BCrypt only uses the first 72 bytes of a password.Argon2PasswordEncoder— winner of the Password Hashing Competition, memory-hard so GPU attacks are expensive. Requires the BouncyCastle library.SCryptPasswordEncoder— also memory-hard. Requires BouncyCastle.Pbkdf2PasswordEncoder— use when FIPS compliance is required.NoOpPasswordEncoder— stores plain text. Deprecated and only for legacy tests; never use it in real code.
DelegatingPasswordEncoder and the {id} Prefix
PasswordEncoderFactories.createDelegatingPasswordEncoder() returns an encoder that stores the algorithm name in front of each hash, for example {bcrypt}$2a$10$... or {argon2@SpringSecurity_v5_8}$argon2id$.... When checking a password it reads the prefix and delegates to the right algorithm.
This means you can change your default algorithm tomorrow without breaking existing users: old hashes still verify with their old algorithm, and new hashes use the new one.
Upgrading Hashes on Login
DaoAuthenticationProvider asks the encoder upgradeEncoding(hash) after a successful login. If the answer is true and a UserDetailsPasswordService bean exists, Spring Security re-hashes the password the user just typed with the current algorithm and calls updatePassword so you can save it. Over a few months, active users migrate to the stronger hash without resetting their passwords.
Checking for Compromised Passwords
Spring Security includes a CompromisedPasswordChecker API with a HaveIBeenPwnedRestApiPasswordChecker implementation. It sends only the first five characters of the password's SHA-1 hash to the Have I Been Pwned service (k-anonymity), so the real password never leaves your server. Use it on registration and password change to reject passwords that already appear in public breaches.
Examples
Encoding and matching passwords
PasswordEncoder encoder = PasswordEncoderFactories.createDelegatingPasswordEncoder();
String hash1 = encoder.encode("correct-horse-battery");
String hash2 = encoder.encode("correct-horse-battery");
System.out.println(hash1);
System.out.println(hash2);
System.out.println("same hash? " + hash1.equals(hash2));
System.out.println("matches? " + encoder.matches("correct-horse-battery", hash1));
System.out.println("wrong pwd? " + encoder.matches("wrong", hash1));
{bcrypt}$2a$10$8XJq1lZ0uXk9s2e5b3m1eOq7m0p2Qm0Y1l0ZkJfY5uS0dJ7cXo0yW
{bcrypt}$2a$10$Qm3rF1n2Lx8Hk5s0Tq9pUe2dYc1bN7aR4vW6zP0oK3jH8gS5fD2aC
same hash? false
matches? true
wrong pwd? false
Choosing Argon2 as the default while still accepting old bcrypt hashes
@Bean
PasswordEncoder passwordEncoder() {
String defaultId = "argon2";
Map<String, PasswordEncoder> encoders = new HashMap<>();
encoders.put("argon2", Argon2PasswordEncoder.defaultsForSpringSecurity_v5_8());
encoders.put("bcrypt", new BCryptPasswordEncoder(12));
return new DelegatingPasswordEncoder(defaultId, encoders);
}
// pom.xml — Argon2 needs BouncyCastle on the classpath
// <dependency>
// <groupId>org.bouncycastle</groupId>
// <artifactId>bcprov-jdk18on</artifactId>
// </dependency>
New hashes: {argon2}$argon2id$v=19$m=16384,t=2,p=1$...
Old hashes: {bcrypt}$2a$10$... -> still verify correctly
Automatically upgrading old hashes when users log in
@Service
public class DatabaseUserDetailsService implements UserDetailsService, UserDetailsPasswordService {
private final AppUserRepository users;
public DatabaseUserDetailsService(AppUserRepository users) {
this.users = users;
}
@Override
public UserDetails loadUserByUsername(String email) {
AppUser u = users.findByEmailIgnoreCase(email)
.orElseThrow(() -> new UsernameNotFoundException(email));
return User.withUsername(u.getEmail()).password(u.getPasswordHash())
.roles(u.getRoles().toArray(String[]::new)).build();
}
// Called by Spring Security after a successful login when the stored hash is outdated
@Override
@Transactional
public UserDetails updatePassword(UserDetails user, String newEncodedPassword) {
AppUser u = users.findByEmailIgnoreCase(user.getUsername()).orElseThrow();
u.setPasswordHash(newEncodedPassword);
return User.withUserDetails(user).password(newEncodedPassword).build();
}
}
Before login: password_hash = {bcrypt}$2a$10$...
After login: password_hash = {argon2}$argon2id$v=19$m=16384,t=2,p=1$...
Rejecting breached passwords at registration
@Bean
CompromisedPasswordChecker compromisedPasswordChecker() {
return new HaveIBeenPwnedRestApiPasswordChecker();
}
@PostMapping("/register")
public ResponseEntity<?> register(@Valid @RequestBody RegisterRequest req) {
if (compromisedPasswordChecker.check(req.password()).isCompromised()) {
return ResponseEntity.badRequest()
.body(Map.of("error", "This password appeared in a data breach. Choose another."));
}
// ... hash and save as usual
return ResponseEntity.status(HttpStatus.CREATED).build();
}
POST /api/auth/register {"password":"password123"} -> 400 {"error":"This password appeared in a data breach. Choose another."}
POST /api/auth/register {"password":"violet-otter-canoe-47"} -> 201 Created
Common Mistakes
- Hashing passwords with SHA-256 or MD5 because they are "secure hashes" — they are far too fast for password storage.
- Comparing hashes with equals() instead of encoder.matches(); salted hashes of the same password are never equal.
- Creating new BCryptPasswordEncoder() in several places with different strengths instead of one PasswordEncoder bean.
- Storing hashes without the {id} prefix, which makes future algorithm migration painful.
- Setting a bcrypt strength so high (for example 16) that each login takes seconds and becomes a denial-of-service vector.
Key Points to Remember
- Passwords are hashed with slow, salted, adaptive algorithms — never encrypted and never fast-hashed.
- Use PasswordEncoderFactories.createDelegatingPasswordEncoder(); its {id} prefix enables painless algorithm changes.
- BCrypt is a solid default; Argon2 is the strongest widely available option.
- Implement UserDetailsPasswordService to upgrade old hashes transparently on login.
- CompromisedPasswordChecker blocks passwords known from public breaches without sending the password anywhere.
Practice the examples
Change an input, predict the result, then compare it with the output. Explain why the result changes.
Use your local JDK or project IDE for these examples. Codelab currently runs Python and HTML/CSS/JavaScript; framework examples may need project dependencies.