Skip to main content
the auth layer

Auth-Specific Rate Limiting with Spring Security and Redis

6 min read Chapter 35 of 45

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:

  1. Per-IP rate limit. Low threshold for authentication endpoints (10 attempts per minute vs 100 for regular API). Catches single-source brute force.

  2. Per-account rate limit. Track failed attempts per username. After N failures, introduce progressive delays. Catches targeted attacks from distributed IPs.

  3. 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.

  4. 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.