Password Encoding and User Authentication

Every application needs user registration and login. This article builds a complete authentication system — from storing passwords safely to handling failed login attempts.

Never Store Passwords in Plain Text

Store a one-way hash, not the password. BCrypt is the industry standard:

@Bean
public PasswordEncoder passwordEncoder() {
    return new BCryptPasswordEncoder(12);
    // cost factor 12 → ~250ms per hash on modern hardware
    // strong enough to slow down brute-force attacks
}

BCrypt properties:

  • Different salt every time — two users with the same password get different hashes
  • Adjustable cost factor — increase as hardware gets faster
  • Self-contained — the hash includes the algorithm, cost factor, and salt
// Encoding
String hash = passwordEncoder.encode("myPassword123!");
// → "$2a$12$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy"

// Verification
boolean matches = passwordEncoder.matches("myPassword123!", hash);
// → true

The User Entity

@Entity
@Table(name = "users")
@EntityListeners(AuditingEntityListener.class)
public class User implements UserDetails {

    @Id
    @GeneratedValue(strategy = GenerationType.UUID)
    private UUID id;

    @Column(unique = true, nullable = false, length = 50)
    private String username;

    @Column(unique = true, nullable = false, length = 100)
    private String email;

    @Column(name = "password_hash", nullable = false)
    private String password;   // BCrypt hash

    @ElementCollection(fetch = FetchType.EAGER)
    @CollectionTable(name = "user_roles",
                     joinColumns = @JoinColumn(name = "user_id"))
    @Column(name = "role")
    @Enumerated(EnumType.STRING)
    private Set<Role> roles = new HashSet<>();

    private boolean enabled = true;

    @Column(name = "account_non_locked")
    private boolean accountNonLocked = true;

    @Column(name = "failed_login_attempts")
    private int failedLoginAttempts = 0;

    @Column(name = "locked_until")
    private Instant lockedUntil;

    @CreatedDate
    @Column(name = "created_at", updatable = false)
    private Instant createdAt;

    @LastModifiedDate
    @Column(name = "updated_at")
    private Instant updatedAt;

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return roles.stream()
            .map(r -> new SimpleGrantedAuthority("ROLE_" + r.name()))
            .toList();
    }

    @Override public String getPassword() { return password; }
    @Override public String getUsername() { return username; }
    @Override public boolean isAccountNonExpired() { return true; }

    @Override
    public boolean isAccountNonLocked() {
        if (!accountNonLocked) return false;
        if (lockedUntil != null && lockedUntil.isAfter(Instant.now())) return false;
        return true;
    }

    @Override public boolean isCredentialsNonExpired() { return true; }
    @Override public boolean isEnabled() { return enabled; }

    // Domain methods
    public void incrementFailedAttempts() {
        failedLoginAttempts++;
        if (failedLoginAttempts >= 5) {
            lockedUntil = Instant.now().plus(15, ChronoUnit.MINUTES);
        }
    }

    public void resetFailedAttempts() {
        failedLoginAttempts = 0;
        lockedUntil = null;
    }
}

public enum Role {
    USER, MANAGER, ADMIN
}
-- Flyway migration
-- V10__create_users_table.sql
CREATE TABLE users (
    id                   UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    username             VARCHAR(50) NOT NULL UNIQUE,
    email                VARCHAR(100) NOT NULL UNIQUE,
    password_hash        VARCHAR(100) NOT NULL,
    enabled              BOOLEAN NOT NULL DEFAULT TRUE,
    account_non_locked   BOOLEAN NOT NULL DEFAULT TRUE,
    failed_login_attempts INTEGER NOT NULL DEFAULT 0,
    locked_until         TIMESTAMPTZ,
    created_at           TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at           TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE TABLE user_roles (
    user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    role    VARCHAR(20) NOT NULL,
    PRIMARY KEY (user_id, role)
);

User Registration

public record RegisterRequest(
    @NotBlank @Size(min = 3, max = 50) String username,
    @NotBlank @Email String email,
    @NotBlank @Size(min = 8, max = 100)
    @Pattern(
        regexp = "^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d)(?=.*[@$!%*?&]).+$",
        message = "Password must contain uppercase, lowercase, digit, and special character"
    )
    String password
) {}

@Service
@RequiredArgsConstructor
public class UserRegistrationService {

    private final UserRepository userRepository;
    private final PasswordEncoder passwordEncoder;

    @Transactional
    public User register(RegisterRequest request) {
        if (userRepository.existsByUsername(request.username())) {
            throw new DuplicateUsernameException(request.username());
        }
        if (userRepository.existsByEmail(request.email())) {
            throw new DuplicateEmailException(request.email());
        }

        User user = new User();
        user.setUsername(request.username());
        user.setEmail(request.email());
        user.setPassword(passwordEncoder.encode(request.password()));  // hash it
        user.setRoles(Set.of(Role.USER));

        return userRepository.save(user);
    }
}

@RestController
@RequestMapping("/api/auth")
@RequiredArgsConstructor
public class AuthController {

    private final UserRegistrationService registrationService;

    @PostMapping("/register")
    public ResponseEntity<UserResponse> register(@RequestBody @Valid RegisterRequest req) {
        User user = registrationService.register(req);
        return ResponseEntity
            .created(URI.create("/api/users/" + user.getId()))
            .body(UserResponse.from(user));
    }
}

Login Endpoint (Credential Validation)

For JWT-based authentication, you validate credentials and return a token:

public record LoginRequest(
    @NotBlank String username,
    @NotBlank String password
) {}

public record LoginResponse(
    String accessToken,
    String tokenType,
    long expiresIn
) {}

@Service
@RequiredArgsConstructor
public class AuthenticationService {

    private final AuthenticationManager authenticationManager;
    private final JwtService jwtService;
    private final UserRepository userRepository;

    public LoginResponse login(LoginRequest request) {
        try {
            // Delegates to DaoAuthenticationProvider → UserDetailsService → PasswordEncoder
            Authentication auth = authenticationManager.authenticate(
                new UsernamePasswordAuthenticationToken(
                    request.username(),
                    request.password()
                )
            );

            UserDetails userDetails = (UserDetails) auth.getPrincipal();

            // Reset failed attempts on success
            userRepository.findByUsername(userDetails.getUsername())
                .ifPresent(user -> {
                    user.resetFailedAttempts();
                    userRepository.save(user);
                });

            String token = jwtService.generateToken(userDetails);
            return new LoginResponse(token, "Bearer", jwtService.getExpirationMs() / 1000);

        } catch (BadCredentialsException e) {
            // Increment failed attempts
            userRepository.findByUsername(request.username())
                .ifPresent(user -> {
                    user.incrementFailedAttempts();
                    userRepository.save(user);
                });
            throw new BadCredentialsException("Invalid username or password");
        }
    }
}

@PostMapping("/login")
public ResponseEntity<LoginResponse> login(@RequestBody @Valid LoginRequest req) {
    LoginResponse response = authService.login(req);
    return ResponseEntity.ok(response);
}

Account Locking

The isAccountNonLocked() check in User.java handles time-based locking automatically. But you need to handle the LockedException thrown by Spring Security:

// In GlobalExceptionHandler
@ExceptionHandler(LockedException.class)
public ResponseEntity<ProblemDetail> handleLocked(LockedException ex, HttpServletRequest req) {
    ProblemDetail p = ProblemDetail.forStatusAndDetail(
        HttpStatus.TOO_MANY_REQUESTS,
        "Account temporarily locked due to too many failed login attempts"
    );
    p.setType(URI.create("https://devopsmonk.com/errors/account-locked"));
    p.setTitle("Account Locked");
    p.setProperty("retryAfter", "15 minutes");
    return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS).body(p);
}

@ExceptionHandler(DisabledException.class)
public ResponseEntity<ProblemDetail> handleDisabled(DisabledException ex, HttpServletRequest req) {
    ProblemDetail p = ProblemDetail.forStatusAndDetail(
        HttpStatus.FORBIDDEN,
        "Account is disabled. Contact support."
    );
    return ResponseEntity.status(HttpStatus.FORBIDDEN).body(p);
}

Password Change

public record ChangePasswordRequest(
    @NotBlank String currentPassword,
    @NotBlank @Size(min = 8, max = 100) String newPassword
) {}

@PutMapping("/api/auth/password")
public ResponseEntity<Void> changePassword(
        @RequestBody @Valid ChangePasswordRequest req,
        @AuthenticationPrincipal UserDetails currentUser) {

    authService.changePassword(currentUser.getUsername(), req);
    return ResponseEntity.noContent().build();
}

@Transactional
public void changePassword(String username, ChangePasswordRequest req) {
    User user = userRepository.findByUsername(username)
        .orElseThrow(() -> new UserNotFoundException(username));

    if (!passwordEncoder.matches(req.currentPassword(), user.getPassword())) {
        throw new BadCredentialsException("Current password is incorrect");
    }

    user.setPassword(passwordEncoder.encode(req.newPassword()));
    userRepository.save(user);
}

Password Reset Flow

A safe password reset flow:

1. User requests reset → POST /api/auth/password-reset/request {email}
2. Server generates time-limited token (not the user's password)
3. Server emails the token (or a link containing it)
4. User submits token + new password → POST /api/auth/password-reset/confirm
5. Server verifies token (not expired, not used)
6. Server updates the password, invalidates the token
@Entity
@Table(name = "password_reset_tokens")
public class PasswordResetToken {

    @Id
    @GeneratedValue(strategy = GenerationType.UUID)
    private UUID id;

    @Column(nullable = false, unique = true)
    private String token;  // random, secure token

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "user_id")
    private User user;

    @Column(name = "expires_at", nullable = false)
    private Instant expiresAt;

    private boolean used = false;

    public boolean isExpired() {
        return Instant.now().isAfter(expiresAt);
    }
}
@Transactional
public void initiatePasswordReset(String email) {
    User user = userRepository.findByEmail(email)
        .orElse(null);  // Don't reveal whether email exists

    if (user != null) {
        String token = UUID.randomUUID().toString();
        PasswordResetToken resetToken = new PasswordResetToken();
        resetToken.setToken(token);
        resetToken.setUser(user);
        resetToken.setExpiresAt(Instant.now().plus(1, ChronoUnit.HOURS));
        tokenRepository.save(resetToken);

        emailService.sendPasswordResetEmail(user.getEmail(), token);
    }

    // Always return success — don't reveal email existence
}

@Transactional
public void confirmPasswordReset(String token, String newPassword) {
    PasswordResetToken resetToken = tokenRepository.findByToken(token)
        .orElseThrow(() -> new InvalidTokenException("Token not found"));

    if (resetToken.isExpired()) {
        throw new InvalidTokenException("Token has expired");
    }
    if (resetToken.isUsed()) {
        throw new InvalidTokenException("Token has already been used");
    }

    User user = resetToken.getUser();
    user.setPassword(passwordEncoder.encode(newPassword));
    user.resetFailedAttempts();
    userRepository.save(user);

    resetToken.setUsed(true);
    tokenRepository.save(resetToken);
}

Email Verification

Require users to verify their email before logging in:

// Add to User entity
private boolean emailVerified = false;

@Override
public boolean isEnabled() {
    return enabled && emailVerified;
}

// On registration, send verification email
@Transactional
public User register(RegisterRequest req) {
    User user = createUser(req);  // enabled=true, emailVerified=false
    String token = generateVerificationToken(user);
    emailService.sendVerificationEmail(user.getEmail(), token);
    return user;
}

// Verification endpoint
@GetMapping("/api/auth/verify")
public ResponseEntity<Void> verifyEmail(@RequestParam String token) {
    authService.verifyEmail(token);
    return ResponseEntity.ok().build();
}

Testing Authentication

@SpringBootTest
@AutoConfigureMockMvc
class AuthControllerTest {

    @Autowired MockMvc mockMvc;
    @Autowired UserRepository userRepository;
    @Autowired PasswordEncoder passwordEncoder;
    @Autowired ObjectMapper mapper;

    @Test
    void shouldRegisterNewUser() throws Exception {
        var req = new RegisterRequest("newuser", "new@example.com", "SecurePass1!");

        mockMvc.perform(post("/api/auth/register")
                .contentType(MediaType.APPLICATION_JSON)
                .content(mapper.writeValueAsString(req)))
            .andExpect(status().isCreated())
            .andExpect(jsonPath("$.username").value("newuser"));
    }

    @Test
    void shouldLoginWithValidCredentials() throws Exception {
        // Setup
        User user = new User();
        user.setUsername("testuser");
        user.setEmail("test@example.com");
        user.setPassword(passwordEncoder.encode("Password1!"));
        user.setRoles(Set.of(Role.USER));
        userRepository.save(user);

        var loginReq = new LoginRequest("testuser", "Password1!");

        mockMvc.perform(post("/api/auth/login")
                .contentType(MediaType.APPLICATION_JSON)
                .content(mapper.writeValueAsString(loginReq)))
            .andExpect(status().isOk())
            .andExpect(jsonPath("$.accessToken").isNotEmpty())
            .andExpect(jsonPath("$.tokenType").value("Bearer"));
    }

    @Test
    void shouldReturn401ForWrongPassword() throws Exception {
        var loginReq = new LoginRequest("testuser", "WrongPassword!");

        mockMvc.perform(post("/api/auth/login")
                .contentType(MediaType.APPLICATION_JSON)
                .content(mapper.writeValueAsString(loginReq)))
            .andExpect(status().isUnauthorized());
    }
}

What You’ve Learned

  • Store passwords with BCryptPasswordEncoder — never plain text, never MD5/SHA1
  • UserDetails + UserDetailsService are the contracts Spring Security uses for authentication
  • authenticationManager.authenticate() validates credentials and returns an Authentication
  • Track failed attempts and lock accounts temporarily to prevent brute-force attacks
  • Password reset requires a time-limited, single-use token sent via email — never the password itself
  • Always respond the same way to “email not found” and “password wrong” in public endpoints — don’t reveal which

Next: Article 25 — JWT Authentication — generate and validate JWTs, build a complete stateless login system.