Skip to main content
the auth layer

Progressive Defenses: Lockout Policies, CAPTCHA, and Breach Detection Integration

7 min read Chapter 36 of 45

Progressive Defenses: Lockout Policies, CAPTCHA, and Breach Detection

The Assumption

Account lockout after N failed attempts protects against brute force. The assumption: locking accounts is a defense with no cost.

Account lockout is a denial-of-service vector. An attacker who knows (or can guess) valid usernames locks every account by sending 5 bad passwords per account. The legitimate users cannot log in. The support team is overwhelmed. The platform is effectively down for affected tenants without the attacker ever needing to compromise a single account.

The Attack

Lockout as DoS. The SaaS platform locks accounts after 5 failed login attempts with a 30-minute lockout window. The attacker scrapes employee email addresses from LinkedIn for tenant “acme-corp.” They send 5 wrong passwords for each of the 200 scraped emails. Every Acme Corp employee is locked out for 30 minutes. The attacker repeats this every 30 minutes. Cost to the attacker: trivial. Cost to Acme Corp: all employees unable to work.

CAPTCHA bypass. The platform adds CAPTCHA after 3 failed attempts. The attacker uses a CAPTCHA-solving service ($2-3 per 1,000 CAPTCHAs). At scale, this costs the attacker $6-9 per day while providing no meaningful barrier to credential stuffing.

Credential stuffing with breached passwords. The attacker does not need to brute force passwords. They already have the password. It was leaked in a breach of another service. 65% of users reuse passwords across services. The attacker’s success rate against reused credentials: near 100% (minus MFA-protected accounts).

The Spec or Mechanism

Progressive defense creates escalating barriers:

Failure countDefenseUser impact
1-3NoneNone
4-5Invisible reCAPTCHA (risk score)None for humans
6-10Visible CAPTCHA5-second delay
11-20Temporary delay (exponential backoff)15-30 second wait
21+Account soft-lock with email unlockMust check email

Soft-lock (vs hard-lock): the account is not disabled. The user unlocks it via email link. This prevents lockout DoS because the legitimate user can always self-service unlock, while the attacker cannot (they do not control the email inbox).

Breached Credential Detection

Check passwords against known breaches at registration and login. The Have I Been Pwned API supports k-anonymity: send the first 5 characters of the SHA-1 hash, receive all hashes with that prefix, check locally. The password never leaves the server.

The Implementation

Soft-Lock with Email Unlock

// VULNERABLE: Hard lockout enables DoS
@Component
public class VulnerableLockoutHandler implements AuthenticationFailureHandler {
    @Override
    public void onAuthenticationFailure(HttpServletRequest request,
            HttpServletResponse response, AuthenticationException exception)
            throws IOException {
        String username = request.getParameter("username");
        int failures = incrementFailures(username);
        if (failures >= 5) {
            // Hard lock: account disabled, requires admin intervention
            disableAccount(username);  // DoS vector
        }
        response.sendError(401, "Invalid credentials");
    }
}
// HARDENED: Soft lock with self-service email unlock
@Component
public class ProgressiveDefenseHandler implements AuthenticationFailureHandler {

    private final RedisTemplate<String, String> redis;
    private final EmailService emailService;
    private final RecaptchaService recaptchaService;

    @Override
    public void onAuthenticationFailure(HttpServletRequest request,
            HttpServletResponse response, AuthenticationException exception)
            throws IOException {

        String username = request.getParameter("username");
        if (username == null) {
            response.sendError(401);
            return;
        }

        long failures = incrementFailures(username);

        if (failures >= 21) {
            // Soft lock: generate unlock token, send email
            String unlockToken = generateSecureToken();
            redis.opsForValue().set(
                "auth:unlock:" + username, unlockToken, Duration.ofHours(1));
            emailService.sendUnlockEmail(username, unlockToken);

            response.setStatus(423); // Locked
            response.setContentType("application/json");
            response.getWriter().write(
                "{\"error\":\"account_locked\","
                + "\"message\":\"Account temporarily locked. Check your email to unlock.\"}");
            return;
        }

        if (failures >= 6) {
            // Require visible CAPTCHA
            response.setStatus(401);
            response.setContentType("application/json");
            response.getWriter().write(
                "{\"error\":\"captcha_required\","
                + "\"message\":\"Invalid credentials. Please complete the CAPTCHA.\"}");
            return;
        }

        response.setStatus(401);
        response.setContentType("application/json");
        response.getWriter().write(
            "{\"error\":\"invalid_credentials\","
            + "\"message\":\"Invalid username or password\"}");
    }

    private long incrementFailures(String username) {
        String key = "auth:failures:" + username;
        Long count = redis.opsForValue().increment(key);
        redis.expire(key, Duration.ofMinutes(30));
        return count != null ? count : 0;
    }
}

Account Unlock Endpoint

@RestController
public class AccountUnlockController {

    private final RedisTemplate<String, String> redis;

    @GetMapping("/auth/unlock")
    public ResponseEntity<String> unlockAccount(
            @RequestParam String token, @RequestParam String email) {

        String storedToken = redis.opsForValue().get("auth:unlock:" + email);

        if (storedToken == null || !MessageDigest.isEqual(
                storedToken.getBytes(StandardCharsets.UTF_8),
                token.getBytes(StandardCharsets.UTF_8))) {
            return ResponseEntity.status(400).body("Invalid or expired unlock link");
        }

        // Clear all auth-related keys for this account
        redis.delete("auth:unlock:" + email);
        redis.delete("auth:failures:" + email);

        return ResponseEntity.ok("Account unlocked. You can now log in.");
    }
}

Breached Credential Check via Have I Been Pwned

@Service
public class BreachedCredentialService {

    private final RestClient restClient;

    public BreachedCredentialService(RestClient.Builder builder) {
        this.restClient = builder
            .baseUrl("https://api.pwnedpasswords.com")
            .defaultHeader("Add-Padding", "true") // Prevent response-length analysis
            .build();
    }

    /**
     * Check if a password appears in known breaches using k-anonymity.
     * Only the first 5 chars of SHA-1 hash are sent to the API.
     */
    public boolean isBreached(String password) {
        String sha1 = sha1Hex(password).toUpperCase();
        String prefix = sha1.substring(0, 5);
        String suffix = sha1.substring(5);

        String responseBody = restClient.get()
            .uri("/range/{prefix}", prefix)
            .retrieve()
            .body(String.class);

        if (responseBody == null) return false;

        // Each line: SUFFIX:COUNT
        return responseBody.lines()
            .anyMatch(line -> {
                String[] parts = line.split(":");
                return parts[0].equals(suffix) && Integer.parseInt(parts[1]) > 0;
            });
    }

    private String sha1Hex(String input) {
        try {
            MessageDigest md = MessageDigest.getInstance("SHA-1");
            byte[] digest = md.digest(input.getBytes(StandardCharsets.UTF_8));
            return HexFormat.of().formatHex(digest);
        } catch (NoSuchAlgorithmException e) {
            throw new RuntimeException(e);
        }
    }
}

Integrate Breach Check into Registration and Login

@Component
public class BreachAwareAuthenticationProvider implements AuthenticationProvider {

    private final DaoAuthenticationProvider delegate;
    private final BreachedCredentialService breachedCredentialService;

    @Override
    public Authentication authenticate(Authentication authentication)
            throws AuthenticationException {

        // First, authenticate normally
        Authentication result = delegate.authenticate(authentication);

        // If authentication succeeds, check if password is breached
        String rawPassword = authentication.getCredentials().toString();
        if (breachedCredentialService.isBreached(rawPassword)) {
            // Authentication succeeds, but flag the password as compromised
            // Do not block login (the user needs to access their account to change it)
            BreachedPasswordAuthentication breachedAuth =
                new BreachedPasswordAuthentication(result);
            breachedAuth.setPasswordBreached(true);
            return breachedAuth;
        }

        return result;
    }

    @Override
    public boolean supports(Class<?> authentication) {
        return delegate.supports(authentication);
    }
}

The Verification

@SpringBootTest
@AutoConfigureMockMvc
class ProgressiveDefenseTest {

    @Autowired
    private MockMvc mockMvc;

    @Autowired
    private RedisTemplate<String, String> redisTemplate;

    @Test
    void softLockDoesNotPreventEmailUnlock() throws Exception {
        // Trigger soft lock (21+ failures)
        for (int i = 0; i < 21; i++) {
            mockMvc.perform(post("/login")
                    .param("username", "[email protected]")
                    .param("password", "wrong"));
        }

        // Account is locked
        mockMvc.perform(post("/login")
                .param("username", "[email protected]")
                .param("password", "correct-password"))
            .andExpect(status().is(423));

        // Retrieve unlock token (simulating email receipt)
        String unlockToken = redisTemplate.opsForValue()
            .get("auth:unlock:[email protected]");
        assertThat(unlockToken).isNotNull();

        // Unlock via email link
        mockMvc.perform(get("/auth/unlock")
                .param("token", unlockToken)
                .param("email", "[email protected]"))
            .andExpect(status().isOk());

        // Can log in again
        mockMvc.perform(post("/login")
                .param("username", "[email protected]")
                .param("password", "correct-password"))
            .andExpect(status().is3xxRedirection());
    }

    @Test
    void breachedPasswordFlaggedButLoginAllowed() throws Exception {
        // Register with a known-breached password
        // Login should succeed but response should indicate password change needed
        MvcResult result = mockMvc.perform(post("/login")
                .param("username", "[email protected]")
                .param("password", "password123")) // Known breached password
            .andExpect(status().is3xxRedirection())
            .andReturn();

        // Session should contain breached password flag
        MockHttpSession session = (MockHttpSession) result.getRequest().getSession();
        assertThat(session.getAttribute("password_breached")).isEqualTo(true);
    }

    @Test
    void captchaRequiredAfterSixFailures() throws Exception {
        for (int i = 0; i < 6; i++) {
            mockMvc.perform(post("/login")
                    .param("username", "[email protected]")
                    .param("password", "wrong"));
        }

        MvcResult result = mockMvc.perform(post("/login")
                .param("username", "[email protected]")
                .param("password", "wrong"))
            .andExpect(status().isUnauthorized())
            .andReturn();

        String body = result.getResponse().getContentAsString();
        assertThat(body).contains("captcha_required");
    }
}

The first test proves that soft-lock is not a DoS vector: the legitimate user can always unlock their account via email. This is the critical difference from hard lockout. The attacker can trigger the lock, but cannot prevent the unlock.

Account lockout is a denial-of-service vulnerability. An attacker who knows a username can lock any account by deliberately failing authentication N times. Customer support receives a flood of lockout tickets. Legitimate users cannot access their accounts. The attacker’s goal was not to break in. It was to deny access.