1. Overview

When building web applications that deal with sensitive data, it’s important to ensure the security of user passwords. One important aspect of password security is checking whether a password is compromised, often due to its presence in a data breach.

Spring Security 6.3 introduces a new feature that allows us to easily check if a password has been compromised.

In this tutorial, we’ll explore the new CompromisedPasswordChecker API in Spring Security and how it can be integrated in our Spring Boot application.

2. Understanding Compromised Passwords

A compromised password is a password that’s exposed in a data breach, making it vulnerable to unauthorized access. Attackers often use these compromised passwords in credential stuffing and password stuffing attacks, using the leaked username-password pairs across multiple sites or common passwords against multiple accounts.

To mitigate this risk, it’s crucial to check if a user’s password is compromised before creating an account against it.

It’s also important to note that a previously valid password can become compromised over time, so it’s always recommended to check for compromised passwords not only during account creation but also during the login process or any process that allows the user to change their passwords. We can prompt a user to reset their password, if a login attempt fails due to detection of a compromised password.

3. The CompromisedPasswordChecker API

Spring Security provides a simple CompromisedPasswordChecker interface for checking if a password has been compromised:

public interface CompromisedPasswordChecker {
    CompromisedPasswordDecision check(String password);
}

This interface exposes a single check() method that takes a password as an input and returns an instance of CompromisedPasswordDecision, indicating whether the password is compromised.

The check() method expects a plaintext password, so we’ll have invoke it before we encrypt our password using a PasswordEncoder.

3.1. Configuring the CompromisedPasswordChecker Bean

To enable compromised password checking in our application, we need to declare of bean of type CompromisedPasswordChecker:

@Bean
public CompromisedPasswordChecker compromisedPasswordChecker() {
    return new HaveIBeenPwnedRestApiPasswordChecker();
}

The HaveIBeenPwnedRestApiPasswordChecker is the default implementation of CompromisedPasswordChecker provided by Spring Security.

This default implementation, integrates with the popular Have I Been Pwned API, which maintains an extensive database of compromised passwords from data breaches.

When the check() method of this default implementation is invoked, it securely hashes the provided password and sends the first 5 characters of the hash to the Have I Been Pwned API. The API responds with a list of hash suffixes that match this prefix. The method then compares the full hash of the password against this list and determines if it’s compromised. The entire check is performed without ever sending the plaintext password over the network.

3.2. Customizing the CompromisedPasswordChecker Bean

@Bean
public CompromisedPasswordChecker customCompromisedPasswordChecker() {
    RestClient customRestClient = RestClient.builder()
      .baseUrl("https://api.proxy.com/password-check")
      .defaultHeader("X-API-KEY", "api-key")
      .build();

    HaveIBeenPwnedRestApiPasswordChecker compromisedPasswordChecker = new HaveIBeenPwnedRestApiPasswordChecker();
    compromisedPasswordChecker.setRestClient(customRestClient);
    return compromisedPasswordChecker;
}

Now, when we call the check() method of our CompromisedPasswordChecker bean in our application, It’ll send the API request to the base URL we’ve defined along with the custom HTTP header.

4. Handling Compromised Passwords

Now that we’ve configured our CompromisedPasswordChecker bean, let’s look at how we can use it in our service layer to validate passwords. Let’s take a common use case of a new user registration:

@Autowired
private CompromisedPasswordChecker compromisedPasswordChecker;

String password = userCreationRequest.getPassword();
CompromisedPasswordDecision decision = compromisedPasswordChecker.check(password);
if (decision.isCompromised()) {
    throw new CompromisedPasswordException("The provided password is compromised and cannot be used.");
}

Here, we simply invoke the check() method with the plaintext password provided by the client and examine the returned CompromisedPasswordDecision. If the isCompromised() method returns true, we throw a CompromisedPasswordException to abort the registration process.

5. Handling the CompromisedPasswordException

When our service layer throws a CompromisedPasswordException, we’d want to handle it gracefully and provide feedback to the client.

One way to do this is to define a global exception handler in a @RestControllerAdvice class:

@ExceptionHandler(CompromisedPasswordException.class)
public ProblemDetail handle(CompromisedPasswordException exception) {
    return ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, exception.getMessage());
}

When this handler method catches a CompromisedPasswordException, it returns an instance of ProblemDetail class, which constructs an error response that’s compliant with the RFC 9457 specification:

{
    "type": "about:blank",
    "title": "Bad Request",
    "status": 400,
    "detail": "The provided password is compromised and cannot be used.",
    "instance": "/api/v1/users"
}

6. Custom CompromisedPasswordChecker Implementation

While the HaveIBeenPwnedRestApiPasswordChecker implementation is a great solution, there might be scenarios where we want to integrate with a different provider, or even implement our own compromised password checking logic.

We can do so by implementing the CompromisedPasswordChecker interface:

public class PasswordCheckerSimulator implements CompromisedPasswordChecker {
    public static final String FAILURE_KEYWORD = "compromised";

    @Override
    public CompromisedPasswordDecision check(String password) {
        boolean isPasswordCompromised = false;
        if (password.contains(FAILURE_KEYWORD)) {
            isPasswordCompromised = true;
        }
        return new CompromisedPasswordDecision(isPasswordCompromised);
    }
}

Our sample implementation considers a password compromised if it contains the word “compromised”. While not very useful in a real-world scenario, it demonstrates how straightforward it’s to plug in our own custom logic.

In our test cases, it’s generally a good practice to use such simulated implementation instead of making an HTTP call to an external API. To use our custom implementation in our tests, we can define it as a bean in a @TestConfiguration class:

@TestConfiguration
public class TestSecurityConfiguration {
    @Bean
    public CompromisedPasswordChecker compromisedPasswordChecker() {
        return new PasswordCheckerSimulator();
    }
}

In our test classes, where we want to use this custom implementation, we’ll annotate it with @Import(TestSecurityConfiguration.class).

Also, to avoid BeanDefinitionOverrideException when running our tests, we’ll annotate our main CompromisedPasswordChecker bean with @ConditionalOnMissingBean annotation.

Finally, to verify the behaviour of our custom implementation, we’ll write a test case:

@Test
void whenPasswordCompromised_thenExceptionThrown() {
    String emailId = RandomString.make() + "@baeldung.it";
    String password = PasswordCheckerSimulator.FAILURE_KEYWORD + RandomString.make();
    String requestBody = String.format("""
            {
                "emailId"  : "%s",
                "password" : "%s"
            }
            """, emailId, password);

    String apiPath = "/users";
    mockMvc.perform(post(apiPath).contentType(MediaType.APPLICATION_JSON).content(requestBody))
      .andExpect(status().isBadRequest())
      .andExpect(jsonPath("$.status").value(HttpStatus.BAD_REQUEST.value()))
      .andExpect(jsonPath("$.detail").value("The provided password is compromised and cannot be used."));
}

7. Creating a Custom @NotCompromised Annotation

As discussed earlier, we should check for compromised passwords not only during user registration, but in all APIs that allow a user to change their passwords or authenticate using their password, such as the login API.

While we can perform this check in the service layer for each of these processes, using a custom validation annotation provides a more declarative and reusable approach.

First, let’s define a custom @NotCompromised annotation:

@Documented
@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = CompromisedPasswordValidator.class)
public @interface NotCompromised {
    String message() default "The provided password is compromised and cannot be used.";
    Class<?>[] groups() default {};
    Class<? extends Payload>[] payload() default {};
}

Next, let’s implement the ConstraintValidator interface:

public class CompromisedPasswordValidator implements ConstraintValidator<NotCompromised, String> {
    @Autowired
    private CompromisedPasswordChecker compromisedPasswordChecker;

    @Override
    public boolean isValid(String password, ConstraintValidatorContext context) {
        CompromisedPasswordDecision decision = compromisedPasswordChecker.check(password);
        return !decision.isCompromised();
    }
}

We autowire an instance of the CompromisedPasswordChecker class and use it to check if the client’s password is compromised.

We can now use our custom @NotCompromised annotation on the password fields of our request bodies and validate their values:

@NotCompromised
private String password;
@Autowired
private Validator validator;

UserCreationRequestDto request = new UserCreationRequestDto();
request.setEmailId(RandomString.make() + "@baeldung.it");
request.setPassword(PasswordCheckerSimulator.FAILURE_KEYWORD + RandomString.make());

Set<ConstraintViolation<UserCreationRequestDto>> violations = validator.validate(request);

assertThat(violations).isNotEmpty();
assertThat(violations)
  .extracting(ConstraintViolation::getMessage)
  .contains("The provided password is compromised and cannot be used.");

8. Conclusion

In this article, we explored how we can use Spring Security’s CompromisedPasswordChecker API to enhance our application’s security by detecting and preventing the use of compromised passwords.

We discussed how to configure the default HaveIBeenPwnedRestApiPasswordChecker implementation. We also talked about customizing it for our specific environment, and even implement our own custom compromised password checking logic.

In conclusion, checking for compromised passwords adds an extra layer of protection for our users’ accounts against potential security attacks.

As always, all the code examples used in this article are available over on GitHub.