Auth-Specific Rate Limiting with Spring Security and Redis
Auth-Specific Rate Limiting with Spring Security and Redis
The Assumption
Rate limiting is rate limiting. Apply the same global rate limit to all endpoints and the auth layer is protected. The assumption: auth endpoints face the same traffic patterns as other APIs.
Auth endpoints are uniquely vulnerable. A login endpoint by design accepts untrusted input (credentials) and responds with a high-value output (session or token). The attacker’s goal is not throughput. It is one successful authentication out of millions of attempts. General rate limiting tuned for API health does not catch low-rate credential stuffing distributed across thousands of source IPs.
The Attack
Distributed credential stuffing. The attacker uses a botnet of 10,000 residential IP addresses. Each IP sends one login attempt every 5 minutes. Total rate: 2,000 attempts per minute. Per-IP rate: 0.2 requests per minute (far below any reasonable rate limit). A global rate limit of 100 requests per minute per IP would not trigger.
The attacker tests credentials from a breach of 2 million email/password pairs. At 2,000 per minute, the full list is tested in about 17 hours. With a typical 0.1-1% reuse rate, the attacker gains access to 2,000-20,000 accounts.
Credential stuffing with human-like patterns. Advanced attackers add jitter (random delays), rotate user agents, solve CAPTCHAs with cheap human labor (CAPTCHA farms), and send requests from residential IPs that look like normal users. No single request looks suspicious.
The Spec or Mechanism
Auth-specific rate limiting uses multiple dimensions:
-
Per-IP rate limit. Low threshold for authentication endpoints (10 attempts per minute vs 100 for regular API). Catches single-source brute force.
-
Per-account rate limit. Track failed attempts per username. After N failures, introduce progressive delays. Catches targeted attacks from distributed IPs.
-
Global authentication rate. Monitor the total authentication failure rate. A sudden spike (from 50 failures/minute to 5,000) indicates an attack even if per-IP and per-account thresholds are not reached.
-
Anomaly detection. Track the ratio of failures to successes. Normal: 5-10% failure rate (typos). During credential stuffing: 95-99% failure rate. During password spraying: normal failure rate but abnormal distribution (many different accounts failing with the same password).
The Implementation
Per-IP Rate Limiting with Bucket4j
@Component
public class AuthRateLimitFilter extends OncePerRequestFilter {
private final Map<String, Bucket> ipBuckets = new ConcurrentHashMap<>();
private final Map<String, Bucket> accountBuckets = new ConcurrentHashMap<>();
private final RedisTemplate<String, String> redisTemplate;
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response, FilterChain chain) throws Exception {
if (!isAuthEndpoint(request)) {
chain.doFilter(request, response);
return;
}
String clientIp = extractClientIp(request);
// Per-IP rate limit: 10 attempts per minute
Bucket ipBucket = ipBuckets.computeIfAbsent(clientIp,
ip -> Bucket.builder()
.addLimit(Bandwidth.classic(10, Refill.intervally(10, Duration.ofMinutes(1))))
.build());
if (!ipBucket.tryConsume(1)) {
response.setStatus(429);
response.setContentType("application/json");
response.getWriter().write(
"{\"error\":\"rate_limited\",\"retry_after\":60}");
return;
}
chain.doFilter(request, response);
}
private boolean isAuthEndpoint(HttpServletRequest request) {
String path = request.getRequestURI();
return path.equals("/login") || path.equals("/oauth2/token")
|| path.equals("/api/auth/login");
}
private String extractClientIp(HttpServletRequest request) {
// Trust X-Forwarded-For only from known reverse proxies
String forwarded = request.getHeader("X-Forwarded-For");
if (forwarded != null && isTrustedProxy(request.getRemoteAddr())) {
return forwarded.split(",")[0].trim();
}
return request.getRemoteAddr();
}
}
Progressive Delays per Account
@Component
public class ProgressiveDelayAuthenticationHandler
implements AuthenticationFailureHandler, AuthenticationSuccessHandler {
private final RedisTemplate<String, String> redisTemplate;
private static final String FAILURE_KEY_PREFIX = "auth:failures:";
@Override
public void onAuthenticationFailure(HttpServletRequest request,
HttpServletResponse response, AuthenticationException exception)
throws IOException {
String username = request.getParameter("username");
if (username == null) return;
String key = FAILURE_KEY_PREFIX + username;
Long failures = redisTemplate.opsForValue().increment(key);
redisTemplate.expire(key, Duration.ofMinutes(30));
// Progressive response delay based on failure count
int delayMs = calculateDelay(failures);
if (delayMs > 0) {
try {
Thread.sleep(delayMs);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.setContentType("application/json");
// Do not reveal whether the account exists
response.getWriter().write(
"{\"error\":\"invalid_credentials\",\"message\":\"Invalid username or password\"}");
}
@Override
public void onAuthenticationSuccess(HttpServletRequest request,
HttpServletResponse response, Authentication authentication)
throws IOException {
String username = authentication.getName();
// Reset failure counter on successful login
redisTemplate.delete(FAILURE_KEY_PREFIX + username);
}
private int calculateDelay(Long failures) {
if (failures <= 3) return 0; // No delay for first 3 attempts
if (failures <= 5) return 1000; // 1 second delay
if (failures <= 10) return 5000; // 5 second delay
if (failures <= 20) return 15000; // 15 second delay
return 30000; // 30 second delay (max)
}
}
Global Authentication Anomaly Detection
@Component
public class AuthAnomalyDetector {
private final RedisTemplate<String, String> redisTemplate;
private final AlertService alertService;
// Called on every authentication attempt
public void recordAttempt(String username, boolean success) {
String minuteKey = "auth:attempts:" + currentMinuteKey();
String failureKey = "auth:failures:" + currentMinuteKey();
redisTemplate.opsForValue().increment(minuteKey);
if (!success) {
redisTemplate.opsForValue().increment(failureKey);
}
redisTemplate.expire(minuteKey, Duration.ofMinutes(5));
redisTemplate.expire(failureKey, Duration.ofMinutes(5));
}
@Scheduled(fixedRate = 60, timeUnit = TimeUnit.SECONDS)
public void checkAnomalies() {
String minuteKey = previousMinuteKey();
Long totalAttempts = getLong("auth:attempts:" + minuteKey);
Long failures = getLong("auth:failures:" + minuteKey);
if (totalAttempts == 0) return;
double failureRate = (double) failures / totalAttempts;
// Normal: 5-15% failure rate. Anomalous: >50%
if (failureRate > 0.5 && totalAttempts > 100) {
alertService.sendAlert(
"AUTH_ANOMALY",
"High authentication failure rate detected: %.1f%% (%d/%d) in the last minute"
.formatted(failureRate * 100, failures, totalAttempts));
}
// Sudden spike: >10x normal volume
Long previousHourAvg = getHourlyAverage();
if (previousHourAvg > 0 && totalAttempts > previousHourAvg * 10) {
alertService.sendAlert(
"AUTH_SPIKE",
"Authentication volume spike: %d attempts (10x normal %d)"
.formatted(totalAttempts, previousHourAvg));
}
}
}
The Verification
@SpringBootTest
@AutoConfigureMockMvc
class AuthRateLimitingTest {
@Autowired
private MockMvc mockMvc;
@Test
void ipRateLimitTriggersAfter10Attempts() throws Exception {
for (int i = 0; i < 10; i++) {
mockMvc.perform(post("/login")
.param("username", "user" + i)
.param("password", "wrong")
.with(request -> {
request.setRemoteAddr("1.2.3.4");
return request;
}))
.andExpect(status().isUnauthorized());
}
// 11th attempt from same IP should be rate limited
mockMvc.perform(post("/login")
.param("username", "user11")
.param("password", "wrong")
.with(request -> {
request.setRemoteAddr("1.2.3.4");
return request;
}))
.andExpect(status().isTooManyRequests());
}
@Test
void progressiveDelayIncreasesWithFailures() throws Exception {
// First 3 failures: no delay
long start = System.currentTimeMillis();
for (int i = 0; i < 3; i++) {
mockMvc.perform(post("/login")
.param("username", "alice")
.param("password", "wrong"))
.andExpect(status().isUnauthorized());
}
long firstThree = System.currentTimeMillis() - start;
// 4th-5th failures: 1 second delay each
start = System.currentTimeMillis();
for (int i = 0; i < 2; i++) {
mockMvc.perform(post("/login")
.param("username", "alice")
.param("password", "wrong"))
.andExpect(status().isUnauthorized());
}
long nextTwo = System.currentTimeMillis() - start;
// Next two should be noticeably slower
assertThat(nextTwo).isGreaterThan(firstThree + 1500);
}
@Test
void successfulLoginResetsFailureCounter() throws Exception {
// Fail 5 times
for (int i = 0; i < 5; i++) {
mockMvc.perform(post("/login")
.param("username", "alice")
.param("password", "wrong"));
}
// Succeed
mockMvc.perform(post("/login")
.param("username", "alice")
.param("password", "correct-password"))
.andExpect(status().is3xxRedirection());
// Next failure should not have progressive delay
long start = System.currentTimeMillis();
mockMvc.perform(post("/login")
.param("username", "alice")
.param("password", "wrong"))
.andExpect(status().isUnauthorized());
long elapsed = System.currentTimeMillis() - start;
assertThat(elapsed).isLessThan(500); // No delay
}
@Test
void errorMessageDoesNotRevealAccountExistence() throws Exception {
// Attempt with non-existent account
MvcResult nonExistent = mockMvc.perform(post("/login")
.param("username", "[email protected]")
.param("password", "anything"))
.andExpect(status().isUnauthorized())
.andReturn();
// Attempt with existing account, wrong password
MvcResult wrongPassword = mockMvc.perform(post("/login")
.param("username", "[email protected]")
.param("password", "wrong"))
.andExpect(status().isUnauthorized())
.andReturn();
// Error messages must be identical
assertThat(nonExistent.getResponse().getContentAsString())
.isEqualTo(wrongPassword.getResponse().getContentAsString());
}
}
The fourth test validates account enumeration prevention: the error message for a non-existent account is identical to the error message for a wrong password. If they differ (e.g., “User not found” vs “Invalid password”), an attacker can enumerate valid email addresses without ever needing to guess a password.