Account Lockout

Why

Attackers try leaked passwords from other sites against your login (credential stuffing) or guess passwords one by one (brute force). Two defences work together:

  • Account lockout: after N failures for one account, block that account for a while.
  • Rate limiting: limit how many attempts one IP (or one client) can make per minute, across all accounts.

A Lockout Counter

Example

Five wrong passwords lock the account for 15 minutes. Even the correct password is refused while locked. Time is passed in, so the example is deterministic (in Spring you inject a Clock):

import java.time.*;
import java.util.*;

public class Main {
    public static void main(String[] args) {
        LoginService svc = new LoginService(Map.of("asha", "S3cret!pass"));
        Instant t = Instant.parse("2026-03-01T10:00:00Z");

        for (int i = 1; i <= 5; i++) {
            System.out.println(time(t.plusSeconds(i)) + " asha / wrong" + i + "  -> " + svc.login("asha", "wrong" + i, t.plusSeconds(i)));
        }
        System.out.println(time(t.plusSeconds(10)) + " asha / correct -> " + svc.login("asha", "S3cret!pass", t.plusSeconds(10)));
        Instant later = t.plus(Duration.ofMinutes(16));
        System.out.println(time(later) + " asha / correct -> " + svc.login("asha", "S3cret!pass", later));
        System.out.println(time(later) + " ravi / guess   -> " + svc.login("ravi", "guess", later));
    }
    static String time(Instant i) { return i.toString().substring(11, 19); }
}

class LoginService {
    static final int MAX_FAILURES = 5;
    static final Duration LOCK_TIME = Duration.ofMinutes(15);

    private final Map<String, String> passwords;          // demo only: real code stores BCrypt hashes
    private final Map<String, Integer> failures = new HashMap<>();
    private final Map<String, Instant> lockedUntil = new HashMap<>();

    LoginService(Map<String, String> passwords) { this.passwords = passwords; }

    String login(String user, String password, Instant now) {
        Instant until = lockedUntil.get(user);
        if (until != null && now.isBefore(until)) return "LOCKED until " + until.toString().substring(11, 19);
        if (until != null) { lockedUntil.remove(user); failures.remove(user); }   // lock expired

        if (password.equals(passwords.get(user))) {
            failures.remove(user);
            return "OK";
        }
        int count = failures.merge(user, 1, Integer::sum);  // unknown users are counted too
        if (count >= MAX_FAILURES) {
            lockedUntil.put(user, now.plus(LOCK_TIME));
            return "BAD_CREDENTIALS, account locked";
        }
        return "BAD_CREDENTIALS (" + count + "/" + MAX_FAILURES + ")";
    }
}

Output

10:00:01 asha / wrong1  -> BAD_CREDENTIALS (1/5)
10:00:02 asha / wrong2  -> BAD_CREDENTIALS (2/5)
10:00:03 asha / wrong3  -> BAD_CREDENTIALS (3/5)
10:00:04 asha / wrong4  -> BAD_CREDENTIALS (4/5)
10:00:05 asha / wrong5  -> BAD_CREDENTIALS, account locked
10:00:10 asha / correct -> LOCKED until 10:15:05
10:16:00 asha / correct -> OK
10:16:00 ravi / guess   -> BAD_CREDENTIALS (1/5)

Note: Lockout lets an attacker lock out a real user on purpose (denial of service). Keep lock times short (15 minutes, growing on repeat), notify the user by email, and show a CAPTCHA after 3 failures instead of locking immediately.