Spring Boot
Password Encoding
Spring Security's PasswordEncoder interface abstracts password hashing. Never store plain-text passwords — always hash with a one-way adaptive algorithm. BCryptPasswordEncoder is the recommended default. DelegatingPasswordEncoder supports multiple encoders simultaneously for migration scenarios. This entry covers the interface, encoder selection, encoding on registration, upgrade strategies, and testing.
PasswordEncoder Interface
PasswordEncoder has two methods: encode() hashes a raw password; matches() verifies a raw password against a stored hash. Both operations are intentionally slow for bcrypt, scrypt, and argon2 — this is by design to frustrate brute-force attacks. Never call encode() to verify a password; always use matches().
Java
// ── The interface ─────────────────────────────────────────────────────
public interface PasswordEncoder {
String encode(CharSequence rawPassword);
boolean matches(CharSequence rawPassword, String encodedPassword);
default boolean upgradeEncoding(String encodedPassword) {
return false; // true signals the encoded password should be
} // re-encoded with the current encoder
}
// ── Available encoders ────────────────────────────────────────────────
//
// BCryptPasswordEncoder — recommended default; work factor configurable
// Argon2PasswordEncoder — memory-hard; strongest against GPU attacks
// SCryptPasswordEncoder — memory-hard; alternative to Argon2
// Pbkdf2PasswordEncoder — FIPS-compliant; slower than bcrypt
// NoOpPasswordEncoder — plain text; tests ONLY — NEVER production
// DelegatingPasswordEncoder — delegates to one of the above by prefix
// ── WRONG — do not compare encoded passwords directly ─────────────────
boolean wrong = storedHash.equals(encoder.encode(rawInput));
// encode() generates a new salt each time — the result is always different
// CORRECT — use matches()
boolean correct = encoder.matches(rawInput, storedHash);BCryptPasswordEncoder
BCryptPasswordEncoder uses the bcrypt algorithm with a configurable work factor (strength). Each call to encode() generates a new random salt and embeds it in the hash string — no separate salt storage is required. The default strength is 10; increase it as hardware improves to keep brute-force cost proportional.
Java
// ── Register as a @Bean ───────────────────────────────────────────────
@Configuration
public class SecurityConfig {
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder(12); // strength 12 (default 10)
}
}
// ── Strength / work factor guide ──────────────────────────────────────
// Strength 10 → ~100ms per hash on modern hardware (minimum)
// Strength 12 → ~400ms per hash (recommended 2024)
// Strength 14 → ~1.6s per hash (high-security)
//
// Rule: choose the highest strength that keeps login latency acceptable.
// Re-evaluate every 2 years as hardware improves.
// ── Encoding on registration ──────────────────────────────────────────
@Service
@RequiredArgsConstructor
public class RegistrationService {
private final UserRepository userRepo;
private final PasswordEncoder passwordEncoder;
@Transactional
public UserResponse register(RegisterRequest request) {
if (userRepo.existsByEmail(request.email())) {
throw new DuplicateEmailException(request.email());
}
User user = new User();
user.setEmail(request.email());
user.setName(request.name());
// Encode before saving — NEVER store raw password
user.setPassword(passwordEncoder.encode(request.password()));
user.setRoles(Set.of("USER"));
user.setEnabled(true);
return UserResponse.from(userRepo.save(user));
}
}
// ── Verifying on login ────────────────────────────────────────────────
// Spring Security calls this automatically via DaoAuthenticationProvider
// but here is what happens internally:
@Service
@RequiredArgsConstructor
public class AuthService {
private final UserRepository userRepo;
private final PasswordEncoder passwordEncoder;
public boolean verifyPassword(String email, String rawPassword) {
User user = userRepo.findByEmail(email).orElseThrow();
return passwordEncoder.matches(rawPassword, user.getPassword());
// matches() extracts the embedded salt from the stored hash
// hashes the raw password with that salt and compares
}
}
// ── BCrypt hash anatomy ────────────────────────────────────────────────
// $2a$12$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy
// ^^ ^^ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^^^^^^^^
// | | 22-char base64 salt 31-char base64 hash
// | work factor (2^12 = 4096 rounds)
// version (2a = bcrypt)DelegatingPasswordEncoder
DelegatingPasswordEncoder stores a prefix in the hash string to identify which encoder produced it — {bcrypt}$2a$..., {argon2}... . It always encodes with the current encoder but can verify hashes produced by any registered encoder. Use it for zero-downtime migration from a weaker algorithm to a stronger one.
Java
// ── DelegatingPasswordEncoder setup ──────────────────────────────────
@Configuration
public class SecurityConfig {
@Bean
public PasswordEncoder passwordEncoder() {
// Use the factory method — registers all built-in encoders
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
// Default encoder: bcrypt
// Registered encoders: bcrypt, ldap, MD4, MD5, noop,
// pbkdf2, scrypt, SHA-1, SHA-256, sha256, argon2
}
// ── Or configure manually for full control ────────────────────────
@Bean
public PasswordEncoder customDelegatingEncoder() {
String encodingId = "bcrypt";
Map<String, PasswordEncoder> encoders = new HashMap<>();
encoders.put("bcrypt", new BCryptPasswordEncoder(12));
encoders.put("argon2", new Argon2PasswordEncoder(
16, 32, 1, 65536, 3));
encoders.put("noop", NoOpPasswordEncoder.getInstance());
return new DelegatingPasswordEncoder(encodingId, encoders);
}
}
// ── Hash format with prefix ───────────────────────────────────────────
// {bcrypt}$2a$12$N9qo8uLOickgx2ZMRZoMye... ← current bcrypt hash
// {argon2}$argon2id$v=19$m=65536,... ← argon2 hash
// {noop}plaintext ← plain text (tests only)
// ── Automatic upgrade during login ────────────────────────────────────
// upgradeEncoding() returns true when stored hash uses a weaker encoder
// DaoAuthenticationProvider re-encodes with the current encoder
// and saves the upgraded hash via UserDetailsPasswordService
@Service
@RequiredArgsConstructor
public class CustomUserDetailsService
implements UserDetailsService, UserDetailsPasswordService {
private final UserRepository userRepo;
private final PasswordEncoder passwordEncoder;
@Override
public UserDetails loadUserByUsername(String email)
throws UsernameNotFoundException {
User user = userRepo.findByEmail(email).orElseThrow(() ->
new UsernameNotFoundException("Invalid credentials"));
return AuthenticatedUser.from(user);
}
// ── Called automatically when upgradeEncoding() returns true ──────
@Override
@Transactional
public UserDetails updatePassword(UserDetails userDetails,
String newEncodedPassword) {
User user = userRepo.findByEmail(userDetails.getUsername())
.orElseThrow();
user.setPassword(newEncodedPassword);
userRepo.save(user);
return AuthenticatedUser.from(user);
}
}Argon2 and PBKDF2 Encoders
Argon2 is the winner of the Password Hashing Competition and resists GPU-based brute-force through configurable memory cost. PBKDF2 is FIPS 140-2 compliant and required in some regulated environments. Both are available as Spring Security PasswordEncoder implementations.
Java
// ── Argon2 — recommended for high-security applications ──────────────
@Bean
public PasswordEncoder argon2Encoder() {
return new Argon2PasswordEncoder(
16, // salt length (bytes)
32, // hash length (bytes)
1, // parallelism
65536, // memory cost (KB) — 64 MB
3 // iterations
);
}
// ── Argon2 parameter guide ────────────────────────────────────────────
// memory: 64 MB minimum; 256 MB recommended for high-value accounts
// iterations: 3 minimum; increase until hash takes ~500ms
// parallelism: set to number of CPU cores available
// ── PBKDF2 — FIPS-compliant environments ─────────────────────────────
@Bean
public PasswordEncoder pbkdf2Encoder() {
return new Pbkdf2PasswordEncoder(
"", // secret (pepper) — empty if not using pepper
16, // salt length (bytes)
310000, // iterations — NIST recommends 310,000 for SHA-256
Pbkdf2PasswordEncoder.SecretKeyFactoryAlgorithm.PBKDF2WithHmacSHA256
);
}
// ── Adding a pepper (application-level secret) ────────────────────────
// A pepper is a server-side secret added before hashing.
// Unlike salt it is not stored in the DB — compromising the DB alone
// is not enough to crack passwords.
@Bean
public PasswordEncoder pepperedBcrypt(
@Value("${app.security.pepper}") String pepper) {
return new BCryptPasswordEncoder(12) {
@Override
public String encode(CharSequence rawPassword) {
return super.encode(rawPassword + pepper);
}
@Override
public boolean matches(CharSequence rawPassword,
String encodedPassword) {
return super.matches(rawPassword + pepper, encodedPassword);
}
};
}
// ── Encoder comparison ────────────────────────────────────────────────
//
// Encoder Memory-hard FIPS Speed (str 12) Recommendation
// ──────────────────────────────────────────────────────────────────
// BCrypt No No ~400ms Default — widely used
// Argon2id Yes No Configurable Best security (2024)
// PBKDF2 No Yes Configurable Regulated environments
// SCrypt Yes No Configurable Alternative to Argon2Password Validation and Policy
Enforce password policy at registration and password-change time using Bean Validation or a dedicated policy component. Common rules include minimum length, complexity (uppercase, digit, special character), and breach checking via the Have I Been Pwned API.
Java
// ── Password policy validator ─────────────────────────────────────────
@Component
public class PasswordPolicyValidator {
private static final int MIN_LENGTH = 12;
private static final int MAX_LENGTH = 128;
private static final Pattern HAS_UPPER =
Pattern.compile("[A-Z]");
private static final Pattern HAS_LOWER =
Pattern.compile("[a-z]");
private static final Pattern HAS_DIGIT =
Pattern.compile("[0-9]");
private static final Pattern HAS_SPECIAL =
Pattern.compile("[^A-Za-z0-9]");
public void validate(String password) {
List<String> violations = new ArrayList<>();
if (password.length() < MIN_LENGTH)
violations.add("at least " + MIN_LENGTH + " characters");
if (password.length() > MAX_LENGTH)
violations.add("no more than " + MAX_LENGTH + " characters");
if (!HAS_UPPER.matcher(password).find())
violations.add("an uppercase letter");
if (!HAS_LOWER.matcher(password).find())
violations.add("a lowercase letter");
if (!HAS_DIGIT.matcher(password).find())
violations.add("a digit");
if (!HAS_SPECIAL.matcher(password).find())
violations.add("a special character");
if (!violations.isEmpty()) {
throw new PasswordPolicyException(
"Password must contain: " +
String.join(", ", violations));
}
}
}
// ── Custom constraint annotation ──────────────────────────────────────
@Target({ElementType.FIELD, ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = StrongPasswordValidator.class)
public @interface StrongPassword {
String message() default "Password does not meet security requirements";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
@Component
public class StrongPasswordValidator
implements ConstraintValidator<StrongPassword, String> {
@Autowired
private PasswordPolicyValidator policy;
@Override
public boolean isValid(String value, ConstraintValidatorContext ctx) {
if (value == null) return false;
try {
policy.validate(value);
return true;
} catch (PasswordPolicyException ex) {
ctx.disableDefaultConstraintViolation();
ctx.buildConstraintViolationWithTemplate(ex.getMessage())
.addConstraintViolation();
return false;
}
}
}
// ── Use on DTO ────────────────────────────────────────────────────────
public record RegisterRequest(
@NotBlank @Email String email,
@NotBlank @Size(min = 2, max = 100) String name,
@NotBlank @StrongPassword String password
) {}