Rate limiting beyond IP throttling: user-intent-aware request control

By Emilia Wójcik · 21 July 2026113 views
Rate limiting beyond IP throttling: user-intent-aware request control

A security engineer is reviewing logs after a credential stuffing attack. The attacker tested 2.3 million username/password combinations over 18 hours. The API never rate-limited a single request. Each IP address in the attack submitted fewer than 10 requests per hour — well within the 100 requests per hour IP-based limit. The attack used 240,000 unique IP addresses across 47 countries, sourced from a botnet of residential proxies.

The IP-based rate limiter worked correctly. Every IP was within limits. The attack succeeded not because the rate limiter was misconfigured but because IP-based rate limiting is the wrong tool for credential stuffing. The adversary controlled enough IP addresses that IP-based limiting was irrelevant.

This is the fundamental limitation of IP-based rate limiting: it assumes the adversary is constrained to a small number of IP addresses. Modern attackers are not. Residential proxy networks rent access to thousands of legitimate residential IP addresses, each contributing a small number of requests, each indistinguishable from normal user traffic.

What rate limiting is actually trying to prevent

Rate limiting has multiple goals, and the right implementation depends on which goal applies.

Resource exhaustion protection. A single client, whether legitimate or malicious, that submits requests faster than the server can process them will degrade service for all users. IP-based rate limiting is appropriate here: an IP that submits 1,000 requests per minute when the API serves 10 requests per minute per client is clearly abusing the resource.

Brute force prevention. An attacker that tests many values for a specific parameter — passwords, verification codes, reset tokens — should be limited per target, not per source IP. The relevant unit is the target account, not the attacker's IP.

API abuse and scraping. A scraper that downloads every product, user profile, or listing should be limited per authenticated session or per device fingerprint, not per IP — scrapers rotate IPs.

Account enumeration. An attacker testing whether email addresses are registered should be limited per email address, not per IP — enumeration probes each email address a small number of times from many IPs.

Different attacks require different limiting dimensions.

Brute force prevention: limit per target

For password attempts, the limiting unit should be the target account, not the source IP. An account that receives 5 failed login attempts in 10 minutes, regardless of source IP, is being brute-forced.

import time
from dataclasses import dataclass
from typing import Optional

@dataclass
class RateLimitResult:
    allowed: bool
    remaining: int
    reset_after_seconds: int
    reason: Optional[str] = None

def check_login_rate_limit(email: str, ip_address: str) -> RateLimitResult:
    """
    Rate limits login attempts with multiple dimensions:
    - Per target account (email): prevents credential stuffing regardless of IP count
    - Per IP address: prevents single-IP brute force
    - Combined signal: per-IP attempts across multiple accounts signals botnet activity
    """
    now = int(time.time())

    # Per-account limit: 5 failed attempts per 10 minutes
    account_key = f"login_fails:account:{email}"
    account_fails = redis.get_count(account_key)

    if account_fails >= 5:
        return RateLimitResult(
            allowed=False,
            remaining=0,
            reset_after_seconds=redis.get_ttl(account_key),
            reason="account_limit_exceeded"
        )

    # Per-IP limit: 20 attempts per 10 minutes across all accounts
    ip_key = f"login_attempts:ip:{ip_address}"
    ip_attempts = redis.get_count(ip_key)

    if ip_attempts >= 20:
        return RateLimitResult(
            allowed=False,
            remaining=0,
            reset_after_seconds=redis.get_ttl(ip_key),
            reason="ip_limit_exceeded"
        )

    return RateLimitResult(allowed=True, remaining=5 - account_fails, reset_after_seconds=600)

def record_login_failure(email: str, ip_address: str) -> None:
    """Call this after a failed login attempt — not after a successful one."""
    redis.increment_with_expiry(f"login_fails:account:{email}", ttl=600)
    redis.increment_with_expiry(f"login_attempts:ip:{ip_address}", ttl=600)

The critical difference from IP-only rate limiting: a credential stuffing attack using 240,000 IPs still hits the per-account limit after 5 failures per account. The attack is blocked at the account level even when no single IP exceeds limits.

Detecting distributed attacks: the cross-IP signal

An IP that submits 5 failed login attempts for 5 different accounts in 10 minutes is generating a distributed attack signal. No single IP-account pair exceeds limits. The pattern across accounts reveals the attack.

def detect_distributed_attack(ip_address: str, email: str) -> bool:
    """
    Detects when an IP is testing multiple accounts — a signal of credential stuffing
    even when per-account and per-IP counts are within limits.
    """
    # Track unique accounts this IP has attempted
    ip_accounts_key = f"login_accounts:ip:{ip_address}"
    redis.sadd(ip_accounts_key, email)
    redis.expire(ip_accounts_key, 600)  # 10-minute window

    unique_accounts = redis.scard(ip_accounts_key)

    # An IP that has tested more than 10 distinct accounts in 10 minutes
    # is exhibiting credential stuffing behavior
    if unique_accounts > 10:
        threat_intelligence.flag_ip(
            ip_address,
            reason="distributed_account_testing",
            account_count=unique_accounts
        )
        return True

    return False

The same logic applies at a broader scale: across thousands of IPs, a sudden increase in failed login attempts across a large number of accounts — even if no single IP exceeds its limit — is a distributed attack signal. Time-series anomaly detection on the aggregate failed login rate is more effective than per-IP limits for detecting distributed attacks.

API scraping: limit per authenticated identity

For authenticated APIs, rate limiting should apply to the authenticated identity, not the IP. A token that is making 10,000 requests per hour from 20 different IP addresses is being used by a scraper, regardless of whether any single IP exceeds limits.

def check_api_rate_limit(api_key: str, endpoint: str) -> RateLimitResult:
    """
    Rate limits authenticated API requests per key, not per IP.
    The key identifies the client; the IP is irrelevant for scraping prevention.
    """
    # Tier-based limits from the API key's subscription level
    tier = api_key_service.get_tier(api_key)
    limits = TIER_LIMITS[tier]  # e.g., {"requests_per_minute": 60, "requests_per_day": 10000}

    minute_key = f"api_rate:key:{api_key}:minute:{int(time.time() // 60)}"
    day_key = f"api_rate:key:{api_key}:day:{int(time.time() // 86400)}"

    minute_count = redis.get_count(minute_key)
    day_count = redis.get_count(day_key)

    if minute_count >= limits["requests_per_minute"]:
        return RateLimitResult(
            allowed=False,
            remaining=0,
            reset_after_seconds=60 - (int(time.time()) % 60),
            reason="per_minute_limit"
        )

    if day_count >= limits["requests_per_day"]:
        return RateLimitResult(
            allowed=False,
            remaining=0,
            reset_after_seconds=86400 - (int(time.time()) % 86400),
            reason="daily_limit"
        )

    redis.increment_with_expiry(minute_key, ttl=60)
    redis.increment_with_expiry(day_key, ttl=86400)

    return RateLimitResult(
        allowed=True,
        remaining=min(
            limits["requests_per_minute"] - minute_count - 1,
            limits["requests_per_day"] - day_count - 1
        ),
        reset_after_seconds=60 - (int(time.time()) % 60)
    )

For unauthenticated APIs (where scraping is possible without authentication), device fingerprinting — combining user agent, TLS fingerprint, browser behavior signals, and cookie/localStorage state — provides a more stable identity than IP address.

Returning the right response

Rate limit responses communicate information to both legitimate users and attackers. The headers provide information about limits and reset times:

def apply_rate_limit_headers(response: Response, result: RateLimitResult) -> Response:
    response.headers["X-RateLimit-Remaining"] = str(result.remaining)
    response.headers["X-RateLimit-Reset"] = str(int(time.time()) + result.reset_after_seconds)
    response.headers["Retry-After"] = str(result.reset_after_seconds)

    if not result.allowed:
        response.status_code = 429
        response.body = {
            "error": "rate_limit_exceeded",
            "message": "Too many requests. Please wait before retrying.",
            "retry_after_seconds": result.reset_after_seconds
        }

    return response

For credential stuffing specifically, the response for a rate-limited login attempt must not distinguish between "account limit exceeded" and "IP limit exceeded." Telling the attacker which limit was hit reveals information about the attack's effectiveness. Return the same 429 response regardless of which limit triggered.

The limits of rate limiting

Rate limiting reduces the effectiveness of automated attacks. It does not make automated attacks impossible. A credential stuffing attack that is correctly rate-limited to 5 attempts per account per 10 minutes can still test 300 passwords per account per hour across a 10-hour window — a meaningful attack throughput for common passwords.

Rate limiting is one layer of a defense-in-depth strategy. The other layers: password breach detection (checking submitted passwords against known breach databases), anomaly detection (login attempts from unusual geographic locations), multi-factor authentication (makes credential stuffing ineffective regardless of password match rate), and CAPTCHA for high-risk actions (slows automated attacks even when not rate-limited).

The security engineer reviewing the credential stuffing logs was working with a rate limiter that correctly enforced IP limits. The missing pieces were account-level limiting and distributed attack detection — two additions that would have blocked the attack within the first few thousand attempts rather than allowing it to run for 18 hours.

Common mistakes in rate limiting implementations

Using fixed windows instead of sliding windows. A fixed window rate limiter that allows 100 requests per minute resets its counter at the top of each minute. An attacker who knows this can send 100 requests at 11:59 and 100 requests at 12:00 — 200 requests in a two-second window, neither batch exceeding the per-minute limit. A sliding window (or token bucket) prevents this by counting requests in the window that ends at the current moment, not at the top of the clock minute.

# Sliding window using Redis sorted sets
def sliding_window_rate_limit(key: str, limit: int, window_seconds: int) -> bool:
    now = time.time()
    window_start = now - window_seconds

    with redis.pipeline() as pipe:
        # Remove requests outside the window
        pipe.zremrangebyscore(key, 0, window_start)
        # Count requests in the window
        pipe.zcard(key)
        # Add this request
        pipe.zadd(key, {str(now): now})
        # Set expiry
        pipe.expire(key, window_seconds)
        results = pipe.execute()

    current_count = results[1]
    return current_count < limit

Applying rate limits only at the API gateway. A gateway that rate-limits incoming requests does not protect against attacks that originate inside the network perimeter — from compromised services, from developers testing against production, or from internal scripts that misbehave. Critical authentication endpoints should have rate limits enforced at the application layer, not only at the gateway, so that the limit applies regardless of where the request originates.

Not providing useful feedback in rate limit responses. A 429 response that says "Too many requests" without Retry-After headers forces legitimate clients to implement exponential backoff with no information about when to retry. Include Retry-After, X-RateLimit-Remaining, and X-RateLimit-Reset headers on all rate-limited responses. Legitimate clients use these to schedule retries efficiently. Attackers already know they are hitting limits.

Rate limiting without logging. A rate limiter that silently drops requests provides no visibility into attack patterns. Log every rate limit trigger with the key that was limited, the current count, and the limit that was exceeded. These logs are the input for the distributed attack detection that identifies credential stuffing patterns before accounts are compromised.

Applying the same limits to all user tiers. API endpoints that serve both free-tier and paid users with the same rate limits penalize paying users who have legitimate high-usage patterns. Paid tiers should have higher limits (or different limit dimensions) appropriate to their usage contracts. The limits per tier should be defined in configuration, not in code, so they can be adjusted without a deployment.

Combining rate limiting with complementary controls

Rate limiting is most effective as one layer in a defense stack, not as the sole control. Each layer addresses a different attack vector:

Rate limiting (per-account, per-IP, per-session): limits the throughput of automated attacks. Effective against low-sophistication attackers with limited IP resources.

Password breach detection: checks submitted passwords against known breach databases (HaveIBeenPwned API or a local hash database). A credential stuffing attack depends on reusing passwords from breached databases. Detecting breached passwords at login blocks the attack at the credential level regardless of whether rate limits are triggered.

Geographic anomaly detection: a login from a country the user has never accessed from, combined with a failed login attempt, is a signal worth flagging for manual review or step-up authentication.

Multi-factor authentication: makes credential stuffing ineffective for protected accounts. A correct password and a valid MFA token are both required. An attacker who obtains the password from a breach database still needs the MFA token, which is not in the breach database.

CAPTCHA on high-risk actions: slows automated attacks by requiring human interaction. Effective at the final step of a high-risk flow (new device registration, password reset) where the cost of a CAPTCHA to a legitimate user is acceptable relative to the security benefit.

The combination of per-account rate limiting, sliding window implementation, breach password detection, and MFA makes credential stuffing attacks impractical without requiring any single control to be perfect. The security engineer who implements all five layers reduces account takeover risk by multiple orders of magnitude relative to IP-only rate limiting — at the cost of a few days of additional implementation work and modest ongoing operational overhead.

Neither addition is complex to implement. The architecture is straightforward. The operational overhead is minimal. The security improvement is significant. The gap was not in capability — it was in the mental model of what rate limiting is for.

Testing rate limiting implementations

Rate limiting logic is commonly undertested because it requires time-dependent behavior and distributed state (Redis counters). Both can be tested with appropriate mocking:

import pytest
from unittest.mock import patch, MagicMock
from freezegun import freeze_time

class TestAccountLevelRateLimit:

    def test_blocks_after_threshold_exceeded(self):
        """Verify that per-account limiting blocks after 5 failed attempts."""
        email = "[email protected]"
        ip = "192.0.2.1"

        with patch("app.rate_limit.redis") as mock_redis:
            mock_redis.get_count.side_effect = [0, 1, 2, 3, 4, 5]
            mock_redis.get_ttl.return_value = 540

            # First 5 calls should be allowed
            for i in range(5):
                result = check_login_rate_limit(email, ip)
                assert result.allowed

            # 6th call should be blocked at the account level
            result = check_login_rate_limit(email, ip)
            assert not result.allowed
            assert result.reason == "account_limit_exceeded"

    def test_distributed_attack_detection(self):
        """Verify that an IP testing multiple accounts is flagged."""
        ip = "192.0.2.100"
        accounts_tested = [f"user{i}@example.com" for i in range(15)]

        with patch("app.rate_limit.redis") as mock_redis, \
             patch("app.rate_limit.threat_intelligence") as mock_ti:

            # Simulate the set growing as each new account is tested
            mock_redis.sadd.return_value = None
            mock_redis.scard.side_effect = list(range(1, 16))  # 1, 2, ..., 15

            for i, email in enumerate(accounts_tested):
                is_attack = detect_distributed_attack(ip, email)
                if i < 10:
                    assert not is_attack  # Below threshold
                else:
                    assert is_attack  # Threshold exceeded

            # Threat intelligence should have been notified
            mock_ti.flag_ip.assert_called_with(
                ip,
                reason="distributed_account_testing",
                account_count=11
            )

    @freeze_time("2026-07-15 12:00:00")
    def test_sliding_window_prevents_boundary_burst(self):
        """Fixed windows allow double the rate at boundaries. Sliding windows do not."""
        key = "test:sliding:limit"
        limit = 5
        window = 60  # 1 minute

        with patch("app.rate_limit.redis") as mock_redis:
            # Simulate 5 requests already in the window
            mock_redis.zcard.return_value = 5

            # 6th request should be blocked under sliding window
            allowed = sliding_window_rate_limit(key, limit=limit, window_seconds=window)
            assert not allowed

Testing the sliding window implementation against the boundary burst scenario (requests at the boundary of a fixed window) verifies that the implementation is actually a sliding window, not a fixed window with a misleading name.

Rate limiting as a signal, not just a control

Rate limiting has a second function beyond blocking attacks: it produces signals that reveal attack patterns before accounts are compromised. A spike in rate limit triggers on the login endpoint is an early warning that a credential stuffing attack is underway, even if no account has yet been compromised.

Building alerting on rate limit trigger rates — not just on absolute counts but on relative changes from baseline — enables the security team to respond to attacks before they complete:

def monitor_rate_limit_signals(window_minutes: int = 5) -> dict:
    """
    Aggregates rate limit trigger statistics to detect ongoing attacks.
    Returns signals for alerting.
    """
    now = int(time.time())
    window_start = now - (window_minutes * 60)

    recent_triggers = redis.execute_command(
        "ZRANGEBYSCORE", "rate_limit_triggers:login", window_start, now
    )

    unique_accounts_targeted = len(set(t["account"] for t in recent_triggers))
    unique_ips_involved = len(set(t["ip"] for t in recent_triggers))
    trigger_rate = len(recent_triggers) / window_minutes  # triggers per minute

    # A high ratio of unique IPs to unique accounts suggests distributed attack
    distribution_ratio = unique_ips_involved / max(unique_accounts_targeted, 1)

    return {
        "trigger_rate_per_minute": trigger_rate,
        "unique_accounts_targeted": unique_accounts_targeted,
        "unique_ips_involved": unique_ips_involved,
        "distribution_ratio": distribution_ratio,
        "attack_signal": trigger_rate > 100 or distribution_ratio > 3
    }

The combination of rate limiting as a control and rate limit triggers as a signal creates a security posture that both slows attacks and surfaces them for investigation. The security engineer who sees a credential stuffing attack in progress can take additional measures — temporary CAPTCHA enforcement, IP block list updates, proactive user notifications — before accounts are compromised rather than after.

Comments

No comments yet. Be the first!

Sign in to leave a comment.