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

  1. Add a {noop} prefix to all existing plaintext passwords (or a custom prefix for existing hashes)
  2. Implement UserDetailsPasswordService to persist upgrades
  3. 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.encode at 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 UserDetailsPasswordService to 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.