Throttling Failed Login Attempts in Spring Boot
Track login attempts without hurting performance
Repeated login failures from the same source are often a sign of a brute-force attack. Blocking every bad attempt isn’t always the right answer, but letting attackers try an unlimited number of guesses opens the door to account compromise. Many secure systems implement throttling logic to delay or reject repeated failed login attempts.
Tracking and Throttling Failed Login Attempts
Automated tools can send thousands of password guesses in minutes if nothing’s stopping them. Throttling adds friction by detecting repeated failures and putting temporary blocks in place. This reduces the chance of someone brute-forcing their way into an account. To make this work in a Spring Boot app, you’ll need to track login failures and block future attempts if they come too fast or too frequently.
This tracking needs to happen somewhere that the application can reach quickly on each login attempt. You don’t want to wait on heavy database queries every time someone types a wrong password. Let’s look at what that setup usually looks like, and how to plug it into the login process.
Storing Attempt History
There’s no single place where this failure data must live, but it needs to be fast, accessible, and reliable for short-term tracking. For small applications, in-memory storage using a concurrent map works fine. It lets you track attempts without setting up anything external. That said, this kind of setup won’t survive application restarts and isn’t shared across multiple instances.
Here’s a basic tracker using a ConcurrentHashMap
. Each login source, either a username or an IP, gets a record. Every time there’s a failed attempt, the counter goes up. A timestamp marks the last time the login failed.
@Component
public class AttemptTracker {
private final int limit = 5;
private final long window = 15 * 60 * 1000;
private final Map<String, Attempt> attempts = new ConcurrentHashMap<>();
public void record(String source) {
attempts.compute(source, (key, attempt) -> {
long now = System.currentTimeMillis();
if (attempt == null || now - attempt.lastAttempt > window) {
attempt = new Attempt(); // reset if outside cooldown window
}
attempt.count++;
attempt.lastAttempt = now;
return attempt;
});
}
public boolean isBlocked(String source) {
Attempt attempt = attempts.get(source);
if (attempt == null) return false;
long now = System.currentTimeMillis();
if (now - attempt.lastAttempt > window) {
attempts.remove(source);
return false;
}
return attempt.count >= limit;
}
public void clear(String source) {
attempts.remove(source);
}
static class Attempt {
volatile int count = 0;
volatile long lastAttempt = 0;
}
}
This gives you a short-term memory of login failures. It resets if the app restarts or the cooldown passes. If you want this to work across multiple application nodes or persist between restarts, you’d eventually want to store this in something like Redis or a database. But for now, this gets the mechanics across and works in a single-server setup.
Hooking Into The Authentication Process
Spring Security gives you a few places to tap into the login process. Two of the most useful are the success and failure handlers. These run after login either passes or fails and can be used to update your tracker.
Start with the failure handler. This is where the login attempt fails, and that’s when you want to count it. You can pull the IP address from the request or the username from the login form. Here’s how a failure handler can look:
@Component
public class LoginFailure implements AuthenticationFailureHandler {
private final AttemptTracker tracker;
public LoginFailure(AttemptTracker tracker) {
this.tracker = tracker;
}
@Override
public void onAuthenticationFailure(HttpServletRequest request,
HttpServletResponse response,
AuthenticationException exception) throws IOException, ServletException {
String ip = Optional.ofNullable(request.getHeader("X-Forwarded-For"))
.map(h -> h.split(",")[0].trim())
.orElse(request.getRemoteAddr());
tracker.record(ip);
response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Login failed");
}
}
Every failed login from the same IP will be counted. Once the number crosses the limit, that source will be blocked until the cooldown ends.
Now on the other side, there’s the success handler. This is where you reset the failure count. After all, someone who logs in successfully shouldn’t stay blocked.
@Component
public class LoginSuccess implements AuthenticationSuccessHandler {
private final AttemptTracker tracker;
public LoginSuccess(AttemptTracker tracker) {
this.tracker = tracker;
}
@Override
public void onAuthenticationSuccess(HttpServletRequest request,
HttpServletResponse response,
Authentication authentication) throws IOException, ServletException {
String ip = Optional.ofNullable(request.getHeader("X-Forwarded-For"))
.map(h -> h.split(",")[0].trim())
.orElse(request.getRemoteAddr());
tracker.clear(ip);
response.setStatus(HttpServletResponse.SC_OK);
}
}
Both handlers are simple and fast. They don’t interrupt or delay the login flow. They just update the tracker behind the scenes.
To actually use them, wire them into your security configuration. That tells Spring Security to call your custom logic instead of the defaults.
@Configuration
public class SecurityConfig {
private final LoginFailure failureHandler;
private final LoginSuccess successHandler;
private final ThrottleFilter throttleFilter;
public SecurityConfig(LoginFailure failureHandler,
LoginSuccess successHandler,
ThrottleFilter throttleFilter) {
this.failureHandler = failureHandler;
this.successHandler = successHandler;
this.throttleFilter = throttleFilter;
}
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.addFilterBefore(throttleFilter, UsernamePasswordAuthenticationFilter.class)
.formLogin(form -> form
.failureHandler(failureHandler)
.successHandler(successHandler)
)
.authorizeHttpRequests(auth -> auth
.anyRequest().authenticated()
);
return http.build();
}
}
This setup gives you direct control over what happens after login attempts. The throttle logic can expand from here depending on what you’re tracking.
Adding a throttle filter
The success and failure handlers update the counter, but nothing stops requests that have already crossed the limit. A lightweight OncePerRequestFilter
can check AttemptTracker.isBlocked(...)
before Spring Security reaches the authentication logic.
@Component
public class ThrottleFilter extends OncePerRequestFilter {
private final AttemptTracker tracker;
public ThrottleFilter(AttemptTracker tracker) {
this.tracker = tracker;
}
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
String ip = Optional.ofNullable(request.getHeader("X-Forwarded-For"))
.map(h -> h.split(",")[0].trim())
.orElse(request.getRemoteAddr());
if (tracker.isBlocked(ip)) {
response.sendError(HttpServletResponse.SC_TOO_MANY_REQUESTS,
"Too many failed log-in attempts. Try again later.");
return;
}
filterChain.doFilter(request, response);
}
}
Choosing Between IP And Username
The question of what to track against, IP or username, depends on the kind of abuse you’re trying to stop. Blocking by IP makes sense when you’re dealing with repeated failed attempts from the same place. It stops someone from using one connection to hammer multiple accounts. The problem is that IPs can be shared. A company network or a classroom can have dozens of users behind a single IP, and blocking that IP could block everyone at once.
On the other hand, blocking by username focuses protection on individual accounts. It prevents attackers from focusing all their guesses on one user. But if they rotate IPs or use a botnet, they can avoid blocks based on IP.
There’s no rule that says you have to pick one. You can build your tracker to handle both. That way, repeated attempts from the same IP get blocked, and repeated attempts on the same username do too. You could even track the combination of both for extra safety.
String combinedKey = ip + ":" + username;
tracker.record(combinedKey);
That key would only track attempts that match both the IP and the username. It adds more granularity, and if one or the other stays low, it won’t trigger the block right away. In general, start with IP-based throttling first. It’s quick to build and helps against bulk login attempts. Then layer in username-based tracking if your system is likely to be targeted by attacks focused on specific accounts.
Managing Wait Times and Cooldown Logic
Blocking someone forever after a few failed logins isn’t practical. People make mistakes, forget passwords, or type in the wrong email by accident. That doesn’t mean they should be locked out for the rest of the day. A cooldown mechanism gives those users a second chance after a short pause, while still holding off anyone trying to hammer the system with password guesses. This comes down to tracking how long ago the last failed attempt happened and making decisions based on that.
The application doesn’t need to do anything fancy to get this working. It just needs to remember when the last attempt happened, compare that to the current time, and see if enough time has passed to let someone try again. Let’s walk through how that works and how to store it in a way that scales.
Tracking Expiration With A Timestamp
Time-based throttling usually relies on two pieces of data: how many times someone has failed, and when the last one happened. The counter by itself isn’t enough. If someone fails five times but then leaves the page for half an hour, they shouldn’t still be locked out. You want to reset their counter after the cooldown window passes.
Here’s a simple example of how that check could be handled in code. This version expects the tracker to hold both the number of failed attempts and the timestamp of the last one.
public boolean isBlocked(String userId) {
LoginRecord record = tracker.get(userId);
if (record == null) return false;
long now = System.currentTimeMillis();
long timeGap = now - record.lastFailure;
if (timeGap > TimeUnit.MINUTES.toMillis(15)) {
tracker.remove(userId);
return false;
}
return record.failCount >= 5;
}
The fifteen-minute window can be any value. Some systems use five minutes. Others wait an hour. It depends on how aggressive you want the throttle to be. Removing the entry after the cooldown keeps the memory use low and makes room for new login attempts to be counted cleanly.
Scaling With Redis
An in-memory tracker works fine while the application stays on a single server. But once you have more than one running, they won’t share state unless you give them a shared store. Redis is fast, can expire entries on its own, and works well for this kind of tracking. Instead of keeping a timestamp in memory, you can store the failure count in Redis with a built-in timeout. That way, Redis will handle the expiration for you. After the window ends, the key disappears.
Here’s an example using RedisTemplate
in Spring Boot to count failures and set a short expiry:
public void recordFailure(String ip) {
String key = "login:fail:" + ip;
Long count = redisTemplate.opsForValue().increment(key);
if (count == 1) {
redisTemplate.expire(key, Duration.ofMinutes(15));
}
}
This works because Redis only resets the expiration when the key is first created. If the count already exists, it won’t start over. That means the block window always starts with the first failure in that group.
To check if the IP should be blocked, you can just fetch the current value and compare it.
public boolean isBlocked(String ip) {
String key = "login:fail:" + ip;
String value = redisTemplate.opsForValue().get(key);
if (value == null) return false;
int count = Integer.parseInt(value);
return count >= 5;
}
This lets multiple backend instances track login failures as one. As long as they point to the same Redis server, they’ll all see the same data. It’s also safer in production, since Redis handles memory cleanup and can scale better than an in-memory map.
Delaying Logins Instead Of Rejecting
Blocking someone outright stops the attempt completely. Another way to slow things down is to add a delay before even checking the password. That makes automated guessing take much longer without showing a big red stop sign right away. Some systems wait a few seconds before responding if the number of failures is rising.
This delay can be introduced during the authentication check. You don’t want to block threads in a way that hurts your server, so keep the delay short and only use it when you need it.
Here’s a basic idea of how it might look:
public void maybeDelay(String ip) {
int failCount = getFailureCount(ip);
if (failCount >= 3) {
try {
Thread.sleep(2000);
} catch (InterruptedException ignored) {}
}
}
This isn’t always the best idea if your application handles a large number of logins at once. Blocking threads, even for a few seconds, can stack up quickly. It’s better for smaller setups or as a last step before blocking someone completely. In large-scale environments, it’s usually better to let the client know how long to wait and let them handle the delay.
Securing The Rest Of The Stack
Login throttling is just one part of making an application safer. If you’re building a service with public APIs or account-based access, you’ll need to handle other points of failure too. API keys are a way to identify applications or devices, but they don’t protect individual users. Those keys should never be used to authenticate someone’s account. They don’t expire by default, they’re often stored in files, and once someone has it, they can reuse it freely.
A proper login system should always include strong password hashing, token expiration, and protection against session reuse. Throttling just adds a delay that makes brute-force attacks slower. Without the other protections in place, it only slows things down without actually stopping them. Each part of the system that accepts user input or grants access needs to have its own protection. Login endpoints need throttling. Authenticated APIs need token checks and permission checks. Password reset flows need one-time tokens. It all works together to make the full system harder to abuse.
Throttling works best when it’s paired with logging, alerts, and short expiration windows. That way, it can slow down bad actors while letting normal users get back in quickly. Everything mentioned here can be tuned and adjusted without needing third-party add-ons. The core tools are already in Spring Boot, Redis, and Java itself.
Conclusion
Everything that makes login throttling work comes down to tracking, checking, and timing. You record failed attempts, decide if someone should wait, and clear things out once they’ve either cooled off or logged in successfully. The pieces don’t need to be complex to be effective. A few well-placed counters, timestamps, and checks can make brute-force attacks much harder to pull off without slowing down the rest of your system. With Spring Boot, Redis, and a bit of careful wiring, the mechanics hold up without getting in the way of real users.