Password Management: Registration, Reset, and Migration
Secure Registration
Registration is where passwords enter your system. Get it wrong here and no amount of downstream security saves you.
The Registration Flow
User submits form
→ Validate password strength
→ Check username/email uniqueness
→ Encode password with PasswordEncoder
→ Persist user (disabled)
→ Send verification email
→ User clicks link → enable account
Registration Endpoint
@RestController
@RequestMapping("/auth")
public class RegistrationController {
private final UserService userService;
private final PasswordEncoder passwordEncoder;
@PostMapping("/register")
public ResponseEntity<Void> register(@Valid @RequestBody RegistrationRequest request) {
userService.register(request);
return ResponseEntity.status(HttpStatus.CREATED).build();
}
}
RegistrationRequest with Validation
public class RegistrationRequest {
@NotBlank
@Size(min = 3, max = 50)
private String username;
@NotBlank
@Email
private String email;
@NotBlank
@Size(min = 12, message = "Password must be at least 12 characters")
@Pattern(
regexp = "^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d)(?=.*[@$!%*?&]).+$",
message = "Password must contain uppercase, lowercase, digit, and special character"
)
private String password;
@NotBlank
private String confirmPassword;
// getters...
}
UserService Registration Logic
@Service
@Transactional
public class UserService {
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
private final EmailVerificationService emailService;
public void register(RegistrationRequest request) {
if (!request.getPassword().equals(request.getConfirmPassword())) {
throw new PasswordMismatchException("Passwords do not match");
}
if (userRepository.existsByUsername(request.getUsername())) {
throw new UsernameAlreadyExistsException("Username already taken");
}
if (userRepository.existsByEmail(request.getEmail())) {
// Respond identically to avoid username enumeration
// Log the collision internally but return same response
log.warn("Registration attempt for existing email: {}", request.getEmail());
emailService.sendAccountExistsNotification(request.getEmail());
return;
}
User user = new User();
user.setUsername(request.getUsername());
user.setEmail(request.getEmail());
user.setPassword(passwordEncoder.encode(request.getPassword()));
user.setEnabled(false); // require email verification
user.setRoles(Set.of(roleRepository.findByName("ROLE_USER")));
userRepository.save(user);
emailService.sendVerificationEmail(user);
}
}
Key decision: returning an identical response for duplicate email registrations prevents username/email enumeration — an attacker cannot probe which emails are registered by observing the response.
Email Verification Tokens
Generate a secure, time-limited token and persist it alongside the user:
@Entity
public class EmailVerificationToken {
@Id
@GeneratedValue
private Long id;
private String token;
@OneToOne
private User user;
private Instant expiresAt;
public boolean isExpired() {
return Instant.now().isAfter(expiresAt);
}
}
@Service
@Transactional
public class EmailVerificationService {
private final EmailVerificationTokenRepository tokenRepository;
private final UserRepository userRepository;
private final MailSender mailSender;
public void sendVerificationEmail(User user) {
String token = generateSecureToken();
EmailVerificationToken verificationToken = new EmailVerificationToken();
verificationToken.setUser(user);
verificationToken.setToken(token);
verificationToken.setExpiresAt(Instant.now().plus(Duration.ofHours(24)));
tokenRepository.save(verificationToken);
// Send email with link: https://yoursite.com/auth/verify?token=<token>
sendEmail(user.getEmail(), token);
}
public void verifyEmail(String token) {
EmailVerificationToken verificationToken = tokenRepository.findByToken(token)
.orElseThrow(() -> new InvalidTokenException("Invalid verification token"));
if (verificationToken.isExpired()) {
throw new TokenExpiredException("Verification token has expired");
}
User user = verificationToken.getUser();
user.setEnabled(true);
userRepository.save(user);
tokenRepository.delete(verificationToken);
}
private String generateSecureToken() {
byte[] bytes = new byte[32];
new SecureRandom().nextBytes(bytes);
return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
}
}
Password Reset Flow
Password reset is one of the most security-sensitive flows in any application. A compromised reset flow is equivalent to having no authentication at all.
User submits email address
→ Look up account (respond identically if not found)
→ Generate secure reset token
→ Store token hash (not token) in DB
→ Send email with link containing plain token
→ User clicks link
→ Validate token against stored hash
→ Allow password change
→ Invalidate token immediately
→ Invalidate all active sessions
PasswordResetToken Entity
@Entity
public class PasswordResetToken {
@Id
@GeneratedValue
private Long id;
private String tokenHash; // store the hash, not the token
@ManyToOne
private User user;
private Instant expiresAt;
private boolean used;
public boolean isExpired() {
return Instant.now().isAfter(expiresAt);
}
}
Generating the Reset Token
@Service
@Transactional
public class PasswordResetService {
private final PasswordResetTokenRepository tokenRepository;
private final UserRepository userRepository;
private final MailSender mailSender;
public void initiateReset(String email) {
// Always respond the same way — don't reveal whether email exists
Optional<User> userOpt = userRepository.findByEmail(email);
if (userOpt.isEmpty()) {
log.info("Password reset requested for unknown email: {}", email);
return; // silent no-op — caller sees same response as success
}
User user = userOpt.get();
// Invalidate any existing tokens
tokenRepository.deleteByUser(user);
String plainToken = generateSecureToken();
String tokenHash = hashToken(plainToken);
PasswordResetToken resetToken = new PasswordResetToken();
resetToken.setUser(user);
resetToken.setTokenHash(tokenHash);
resetToken.setExpiresAt(Instant.now().plus(Duration.ofMinutes(30)));
resetToken.setUsed(false);
tokenRepository.save(resetToken);
sendResetEmail(user.getEmail(), plainToken);
}
public void resetPassword(String plainToken, String newPassword) {
String tokenHash = hashToken(plainToken);
PasswordResetToken resetToken = tokenRepository.findByTokenHash(tokenHash)
.orElseThrow(() -> new InvalidTokenException("Invalid or expired reset token"));
if (resetToken.isExpired() || resetToken.isUsed()) {
throw new InvalidTokenException("Invalid or expired reset token");
}
User user = resetToken.getUser();
user.setPassword(passwordEncoder.encode(newPassword));
userRepository.save(user);
// Mark token as used immediately
resetToken.setUsed(true);
tokenRepository.save(resetToken);
// Invalidate all sessions
sessionRegistry.getAllSessions(user, false)
.forEach(SessionInformation::expireNow);
}
private String generateSecureToken() {
byte[] bytes = new byte[32];
new SecureRandom().nextBytes(bytes);
return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
}
private String hashToken(String token) {
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
return HexFormat.of().formatHex(digest.digest(token.getBytes(StandardCharsets.UTF_8)));
} catch (NoSuchAlgorithmException e) {
throw new IllegalStateException(e);
}
}
}
Why hash the token before storing? If your database is breached, attackers cannot use the stored token to reset passwords — they have the hash, not the token that the email link contains.
Reset Endpoint
@RestController
@RequestMapping("/auth")
public class PasswordResetController {
private final PasswordResetService resetService;
@PostMapping("/forgot-password")
public ResponseEntity<Void> forgotPassword(@RequestBody ForgotPasswordRequest request) {
resetService.initiateReset(request.getEmail());
// Always return 200 — never reveal whether email exists
return ResponseEntity.ok().build();
}
@PostMapping("/reset-password")
public ResponseEntity<Void> resetPassword(@RequestBody ResetPasswordRequest request) {
resetService.resetPassword(request.getToken(), request.getNewPassword());
return ResponseEntity.ok().build();
}
}
Change Password (Authenticated Users)
For logged-in users changing their own password, always require the current password first:
@RestController
@RequestMapping("/account")
public class AccountController {
private final UserService userService;
private final PasswordEncoder passwordEncoder;
@PostMapping("/change-password")
@PreAuthorize("isAuthenticated()")
public ResponseEntity<Void> changePassword(
@RequestBody ChangePasswordRequest request,
Authentication auth) {
User user = userService.findByUsername(auth.getName());
if (!passwordEncoder.matches(request.getCurrentPassword(), user.getPassword())) {
throw new BadCredentialsException("Current password is incorrect");
}
if (!request.getNewPassword().equals(request.getConfirmNewPassword())) {
throw new PasswordMismatchException("New passwords do not match");
}
String encoded = passwordEncoder.encode(request.getNewPassword());
userService.updatePassword(user, encoded);
return ResponseEntity.ok().build();
}
}
Bulk Password Migration
When migrating an existing system, you typically cannot immediately re-hash all passwords (you do not have the plaintext). The correct approach is lazy migration.
Strategy: Prefix and Upgrade on Login
- Add a
{noop}prefix to all existing plaintext passwords (or a custom prefix for existing hashes) - Implement
UserDetailsPasswordServiceto persist upgrades - Users’ passwords silently upgrade on their next login
-- For plaintext passwords
UPDATE users
SET password = CONCAT('{noop}', password)
WHERE password NOT LIKE '{%}%';
-- For existing bcrypt passwords without prefix (DelegatingPasswordEncoder expects prefix)
UPDATE users
SET password = CONCAT('{bcrypt}', password)
WHERE password LIKE '$2a$%' OR password LIKE '$2b$%';
@Service
public class UserDetailsServiceImpl implements UserDetailsService, UserDetailsPasswordService {
@Override
public UserDetails loadUserByUsername(String username) { ... }
@Override
public UserDetails updatePassword(UserDetails user, String newEncodedPassword) {
// Called automatically by DaoAuthenticationProvider when upgradeEncoding returns true
userRepository.updatePassword(user.getUsername(), newEncodedPassword);
log.info("Password upgraded for user: {}", user.getUsername());
return User.withUserDetails(user).password(newEncodedPassword).build();
}
}
DaoAuthenticationProvider calls updatePassword automatically when it detects upgradeEncoding is true. No changes needed in the authentication flow — just implement the interface.
Tracking Migration Progress
Add a column to track which users have been migrated:
ALTER TABLE users ADD COLUMN password_migrated BOOLEAN DEFAULT FALSE;
@Override
public UserDetails updatePassword(UserDetails user, String newEncodedPassword) {
userRepository.updatePasswordAndSetMigrated(user.getUsername(), newEncodedPassword);
return User.withUserDetails(user).password(newEncodedPassword).build();
}
Query migration progress:
SELECT
COUNT(*) FILTER (WHERE password_migrated) AS migrated,
COUNT(*) FILTER (WHERE NOT password_migrated) AS pending,
COUNT(*) AS total
FROM users;
Force migration for dormant accounts that haven’t logged in: send a “your account needs re-verification” email requiring them to set a new password via the reset flow.
Rate Limiting and Brute Force Protection
Password operations are high-value attack targets. Protect them:
@Component
public class LoginAttemptService {
private final Cache<String, Integer> attemptsCache = CacheBuilder.newBuilder()
.expireAfterWrite(15, TimeUnit.MINUTES)
.build();
private static final int MAX_ATTEMPTS = 5;
public void loginFailed(String username) {
int attempts = attemptsCache.asMap().getOrDefault(username, 0);
attemptsCache.put(username, attempts + 1);
}
public void loginSucceeded(String username) {
attemptsCache.invalidate(username);
}
public boolean isBlocked(String username) {
return attemptsCache.asMap().getOrDefault(username, 0) >= MAX_ATTEMPTS;
}
}
Wire into UserDetailsService:
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
if (loginAttemptService.isBlocked(username)) {
throw new LockedException("Account temporarily locked due to too many failed attempts");
}
// load user...
}
For reset endpoints, apply rate limiting at the IP level to prevent token-generation abuse.
Key Takeaways
- Always encode passwords with
PasswordEncoder.encodeat registration — never store plaintext or reversibly encrypted values - Prevent email/username enumeration by returning identical responses for existing and non-existing accounts
- Store password reset tokens as SHA-256 hashes in the database; send only the plain token in the email
- Reset tokens must be short-lived (30 minutes), single-use, and must invalidate all sessions on use
- Implement
UserDetailsPasswordServiceto enable automatic password algorithm upgrades on login - Bulk migration uses a prefix-and-upgrade strategy — no plaintext ever touches the database again
Next: Session Management: Fixation, Concurrency, and Redis Sessions — how Spring Security creates, validates, and invalidates sessions.