Password Management & Encoding
Storing passwords properly: choosing between BCrypt, Argon2 and scrypt, DelegatingPasswordEncoder for zero-downtime migration, strength rules, and breached-password checks.
On this page
Password storage is a solved problem with a small number of correct answers, and the wrong answers are catastrophic rather than merely suboptimal. Spring Security gets the defaults right; what needs deciding is the algorithm, the cost parameters and the migration path.
Key Takeaways
- Use a slow, salted, adaptive hash. Never SHA-256, never MD5, never a hash you built.
- Argon2id is the current best choice; BCrypt remains fine.
DelegatingPasswordEncodermakes hash migration transparent and gradual.- Cost parameters are a moving target — revisit them every couple of years.
- Check new passwords against breach corpora; length matters far more than symbol rules.
Why general-purpose hashes are wrong
SHA-256 is designed to be fast, which is exactly what you do not want. A modern GPU computes billions of SHA-256 hashes per second, so a stolen table of SHA-256 password hashes is a table of plaintext passwords within hours.
Password hashes are deliberately slow and tunable. A cost parameter sets how much work verification takes; you raise it as hardware improves. Verifying one password at 100ms is invisible to a user and makes brute-forcing millions of guesses impractical.
Salting is the other half. A random per-password salt means identical passwords produce different hashes, which defeats rainbow tables and stops an attacker from seeing that two accounts share a password.
The encoders
| Encoder | Hardness | Notes |
|---|---|---|
Argon2PasswordEncoder | Memory + CPU | Current recommendation (Argon2id) |
BCryptPasswordEncoder | CPU | Mature, ubiquitous, still fine |
SCryptPasswordEncoder | Memory + CPU | Good, less common than the other two |
Pbkdf2PasswordEncoder | CPU | Choose when FIPS compliance requires it |
NoOpPasswordEncoder | None | Deprecated. Tests only, never anywhere else |
Memory-hardness is what distinguishes Argon2 and scrypt. Custom hardware can parallelise CPU work cheaply but cannot cheaply parallelise something that needs 64MB of RAM per guess, so memory-hard functions raise the attacker's cost far more than the defender's.
@Configuration
public class PasswordConfig {
@Bean
public PasswordEncoder passwordEncoder() {
String encodingId = "argon2";
Map<String, PasswordEncoder> encoders = new HashMap<>();
// saltLength, hashLength, parallelism, memory (KB), iterations.
// Tune so verification takes 250-500ms on production hardware.
encoders.put("argon2", new Argon2PasswordEncoder(16, 32, 1, 1 << 16, 3));
// Legacy algorithms stay registered so existing hashes still verify.
encoders.put("bcrypt", new BCryptPasswordEncoder(12));
encoders.put("scrypt", SCryptPasswordEncoder.defaultsForSpringSecurity_v5_8());
var delegating = new DelegatingPasswordEncoder(encodingId, encoders);
// Hashes stored before prefixes existed are assumed BCrypt rather than
// throwing. Remove this once the migration is complete.
delegating.setDefaultPasswordEncoderForMatches(new BCryptPasswordEncoder(10));
return delegating;
}
}Stored hashes carry their algorithm as a prefix, which is what makes several coexist:
{argon2}$argon2id$v=19$m=65536,t=3,p=1$c29tZXNhbHQ$RdescudvJCsgt3ub+b+dWRWJTmaaJObG
{bcrypt}$2a$12$GhvMmNVjRW29ulnudl.LbuAnUtN/LRfe1JsBm1Xu6LE3059z5Tr8mMigrating without a password reset
@Component
public class HashUpgradeListener {
private final UserRepository users;
private final PasswordEncoder encoder;
@EventListener
public void onSuccess(AuthenticationSuccessEvent event) {
Authentication auth = event.getAuthentication();
String rawPassword = String.valueOf(auth.getCredentials());
if (!StringUtils.hasText(rawPassword)) return;
users.findByEmailIgnoreCase(auth.getName()).ifPresent(user -> {
if (encoder.upgradeEncoding(user.passwordHash())) {
user.setPasswordHash(encoder.encode(rawPassword));
users.save(user);
log.info("upgraded password hash for user {}", user.id());
}
});
}
}The population migrates itself as people log in. After a few months, force a reset for the remaining dormant accounts and remove the legacy encoder.
Strength rules that help
Most password policies actively reduce security. Requiring one uppercase, one digit and one symbol
pushes users toward Password1! — which is in every cracking dictionary — while banning the long
passphrase that would actually be strong.
Current guidance, from NIST SP 800-63B onwards, is straightforward: enforce a minimum length of at least 12, allow everything including spaces and unicode, set a generous maximum such as 128, do not require composition rules, do not expire passwords on a schedule, and do check against known-breached passwords.
@Component
public class PasswordPolicy {
private final PasswordValidator validator = new PasswordValidator(List.of(
new LengthRule(12, 128),
new WhitespaceRule(WhitespaceRule.Whitespace.TAB),
// Reject sequences and repeats, which look complex and are not.
new RepeatCharacterRegexRule(4),
new IllegalSequenceRule(EnglishSequenceData.Alphabetical, 5, false),
new IllegalSequenceRule(EnglishSequenceData.Numerical, 5, false),
new UsernameRule(true, true)));
private final BreachedPasswordChecker breached;
public void assertAcceptable(String password, String username) {
var result = validator.validate(new PasswordData(username, password));
if (!result.isValid()) {
throw new WeakPasswordException(validator.getMessages(result));
}
if (breached.isBreached(password)) {
throw new WeakPasswordException(List.of(
"This password has appeared in a known data breach. Please choose another."));
}
}
}Checking against breaches
Have I Been Pwned exposes a k-anonymity API: you send the first five characters of the SHA-1 hash and receive every suffix with that prefix. The service never learns the password, and neither does anyone observing the request.
@Component
public class BreachedPasswordChecker {
private final RestClient client;
public boolean isBreached(String password) {
String sha1 = DigestUtils.sha1Hex(password).toUpperCase(Locale.ROOT);
String prefix = sha1.substring(0, 5);
String suffix = sha1.substring(5);
try {
String body = client.get()
.uri("https://api.pwnedpasswords.com/range/{prefix}", prefix)
.retrieve().body(String.class);
return body != null && body.lines()
.anyMatch(line -> line.startsWith(suffix));
} catch (RestClientException ex) {
// Fail open: a third-party outage must not block registration.
log.warn("breach check unavailable", ex);
return false;
}
}
}Protecting the login endpoint
Strong hashing protects the database; it does nothing against online guessing. Three controls close that gap.
Rate limit per account and per IP. Per-account stops targeted guessing; per-IP stops credential stuffing spread across many accounts. You need both, because either alone is trivially bypassed.
Lock progressively, not permanently. A permanent lock after five failures is a denial-of-service against your own users — anyone can lock any account by guessing wrong. Exponential delays, or a timed lock that clears itself, give the same protection without the weapon.
Return one message for every failure. "No such user" and "wrong password" as distinct responses is a free account-enumeration oracle. Watch timing too: returning instantly for an unknown user and after 300ms of hashing for a known one leaks the same information.
What to take away
Use Argon2id or BCrypt through a DelegatingPasswordEncoder, tune the cost to a few hundred
milliseconds, and let logins migrate old hashes for you. Require length rather than composition,
check against breach corpora, and rate-limit the login endpoint — the hash protects the dump, not the
front door.
Frequently Asked Questions
BCrypt or Argon2?
Do I need to store a salt column?
How do I upgrade hashes without forcing a password reset?
Related tutorials
- Spring Security Architecture Deep DiveHow Spring Security actually works: the filter chain and its ordering, SecurityContextHolder, the AuthenticationManager delegation model, and where to plug in custom logic.
- HTTP Basic & Form-Based AuthenticationThe two classic authentication mechanisms: when Basic is appropriate, configuring form login properly, custom success and failure handlers, logout, and account lockout that is not a DoS.
- In-Memory & JDBC AuthenticationWhere user credentials live: in-memory users for tests, JdbcUserDetailsManager and its schema, writing a custom UserDetailsService, and seeding an initial administrator safely.
- JWT Authentication Deep DiveJWTs done safely: structure and claims, why RS256 beats HS256, key rotation with JWKS, the alg=none and key-confusion attacks, and how to revoke a stateless token.