StackPractices
intermediate By Mathias Paulenko

Rate Limiting for API Security: Block Abuse and Brute Force

Use rate limiting as a security control against brute-force, credential stuffing, scraping, and L7 DDoS. Layered limits, per-endpoint tuning, and 429 handling.

Topics: security

Overview

Rate limiting earns its place in a security stack because most automated abuse shares one trait: volume. Credential stuffing needs thousands of login attempts. Scraping needs every page, fast. An L7 flood needs a request rate you never planned for. A limiter cuts all of these off at the source by making volume expensive.

This recipe treats rate limiting as a defense layer, not a traffic-shaping feature. The implementation algorithms (token bucket, sliding window, fixed window) are covered step by step in API rate limiting and Redis-backed rate limiting. What follows is the security side: which layer blocks what, how to identify clients attackers can’t trivially rotate, how strict each endpoint should be, and what to do after a client trips the limit.

flowchart diagram: Client

When to use it

Deploy rate limiting when you face:

  • Brute-force or credential-stuffing attacks against /login, /auth/token, password reset, or OTP endpoints
  • Scraping of catalogs, search results, or user data at machine speed
  • L7 request floods that stay under the radar of network-layer DDoS protection
  • Cost abuse, where a single client burns through expensive operations — search, exports, AI calls, SMS sending
  • API-key or token leakage, where a stolen credential is getting hammered from one source

Don’t rely on it when:

  • The attack is volumetric (L3/L4) — that’s what your CDN/WAF network defenses absorb
  • Traffic comes from a small set of trusted internal services you control — use authentication and separate quotas instead
  • The endpoint is legitimately high-volume, like a health check or webhook receiver — a generic limit will create false alarms

Choosing limits by attack type

Different abuse needs different limits and different keys:

AttackWhat trips itRecommended limitKey on
Brute-force loginRepeated failures on one account5 attempts/minIP + account
Credential stuffingMany accounts from one source10 attempts/minIP (+ fingerprint)
OTP / reset abuseCode-guessing volume3-5 attempts/minAccount + IP
ScrapingSequential bulk reads60-100 req/minAPI key / user ID
L7 floodRequest rate alone10-20 req/s burstIP
Cost abuseCalls to expensive ops10-30/hourUser ID + endpoint

Two rules of thumb: the more sensitive the endpoint, the tighter and more specific the limit; and the key should be something the attacker can’t rotate cheaply (IPs are cheap, accounts are less so, verified tokens least of all).

Solution

Atomic token bucket in Redis (Lua)

The classic pitfall is doing read-modify-write in two round trips — under concurrency, requests interleave and the limiter lets more through than it should. Run the check-and-decrement atomically inside Redis:

-- token_bucket.lua
-- KEYS[1] = rate limit key (e.g. "rl:login:1.2.3.4")
-- ARGV[1] = capacity, ARGV[2] = refill rate (tokens/sec)
-- ARGV[3] = current unix timestamp, ARGV[4] = key TTL (sec)

local bucket = redis.call('HMGET', KEYS[1], 'tokens', 'last_refill')
local tokens = tonumber(bucket[1]) or tonumber(ARGV[1])
local last_refill = tonumber(bucket[2]) or tonumber(ARGV[3])

tokens = math.min(tonumber(ARGV[1]),
  tokens + (tonumber(ARGV[3]) - last_refill) * tonumber(ARGV[2]))

local allowed = 0
if tokens >= 1 then
  tokens = tokens - 1
  allowed = 1
end

redis.call('HMSET', KEYS[1], 'tokens', tokens, 'last_refill', ARGV[3])
redis.call('EXPIRE', KEYS[1], ARGV[4])
return allowed
import time
import redis

r = redis.Redis(host='localhost', port=6379, db=0)
TOKEN_BUCKET = r.register_script(open('token_bucket.lua').read())

def is_allowed(key: str, capacity: int, refill_rate: float) -> bool:
    return bool(TOKEN_BUCKET(keys=[key],
                             args=[capacity, refill_rate, time.time(), 60]))

# Block brute-force on login: 5 attempts/minute per IP + account
key = f"rl:login:{client_ip}:{account}"
if not is_allowed(key, capacity=5, refill_rate=5/60):
    return Response(status=429, headers={'Retry-After': '12'},
                    body=b"Rate limit exceeded")

Tiered limits with rate-limiter-flexible (Express)

Attack surface varies per route, so the limiter should too — strict on auth, lenient on reads:

const { RateLimiterRedis } = require('rate-limiter-flexible');
const redisClient = require('redis')
  .createClient({ url: process.env.REDIS_URL });

// Auth endpoints: 5 attempts/min, then block 5 min
const authLimiter = new RateLimiterRedis({
  storeClient: redisClient, keyPrefix: 'auth',
  points: 5, duration: 60, blockDuration: 300,
});

// General API: 100 requests/10s
const apiLimiter = new RateLimiterRedis({
  storeClient: redisClient, keyPrefix: 'api',
  points: 100, duration: 10,
});

function createLimiter(limiter, keyFn = (req) => req.ip) {
  return async (req, res, next) => {
    try {
      await limiter.consume(keyFn(req), 1);
      next();
    } catch (rej) {
      const retryAfter = Math.ceil(rej.msBeforeNext / 1000);
      res.set('Retry-After', String(retryAfter));
      res.set('X-RateLimit-Remaining', '0');
      res.status(429).json({ error: 'Too many requests', retryAfter });
    }
  };
}

app.use('/api/', createLimiter(apiLimiter));
// Key on IP + credential: stops stuffing one source across many accounts
app.use('/api/auth/',
  createLimiter(authLimiter, (req) => `${req.ip}:${req.body?.email || ''}`));

Edge limits in Nginx

Cheap, early rejection before requests reach your app:

limit_req_zone $binary_remote_addr zone=auth:10m rate=1r/s;
limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s;

server {
  location /api/auth/ {
    limit_req zone=auth burst=3 nodelay;
    limit_req_status 429;
    limit_req_log_level warn;   # feeds your attack detection
    proxy_pass http://backend;
  }
  location /api/ {
    limit_req zone=api burst=20 nodelay;
    limit_req_status 429;
    proxy_pass http://backend;
  }

  error_page 429 = @ratelimited;
  location @ratelimited {
    add_header Retry-After "60" always;
    return 429 '{"error": "Too many requests", "retryAfter": 60}';
  }
}

Verifying the limits work

Before calling it done, hit an endpoint past its limit and confirm the behavior — a limiter that’s silently misconfigured protects nothing:

# Burst 10 requests; expect the tail to come back 429
for i in $(seq 1 10); do
  curl -s -o /dev/null -w "%{http_code} " \
    -X POST https://api.example.com/api/auth/login \
    -d '{"email":"a@b.c","password":"x"}'
done
# Expected: 200/401 a few times, then 429s with Retry-After

Tuning per endpoint

Start conservative and loosen where legitimate users complain:

  • Auth and recovery endpoints — 3-5/min keyed on IP + account. These almost never see legitimate bursts.
  • Costly reads (search, exports, reports) — 10-30/hour per user.
  • Regular reads — 100+/min per user or token; scraping harm here is slow anyway.
  • Writes and mutations — 20-50/min; mass writes are more damaging than mass reads.

Escalate rather than hard-block: first offense → 429 with a short Retry-After; repeated trips → longer blockDuration; sustained abuse → feed the source to your WAF or captcha challenge. Permanent IP bans are a last resort — shared NATs and mobile carrier gateways mean you’ll ban innocent users sharing the address.

What works

  • Send back 429 with Retry-After and X-RateLimit-* headers — honest clients back off on their own; attackers learn nothing from the response.
  • Key on who, not just where: authenticated user ID, API key, or session token. Limit by IP alone and NATs punish innocents while botnets sail through.
  • Alert on 429 spikes through your monitoring stack. A burst of rejections is usually the first sign of an attack in progress, not a client bug.
  • Pair with WAF and auth controls: the limiter slows attackers, it doesn’t authenticate them. Combine with security headers, strong auth, and captcha for repeat offenders.
  • Fail closed on auth endpoints: if Redis is down, deny login/reset by default and only fail open on low-risk reads.

Common mistakes

  • Keying on IP alone when a proxy sits in front — req.ip then reports the proxy, not the client. Parse X-Forwarded-For from the trusted hop only, or a rotating attacker resets their budget for free.
  • Non-atomic counter updates — read-then-write race conditions let bursts slip through. Use Lua or a library that wraps the check in one atomic call.
  • Same limit everywhere — one global ceiling either starves legitimate users or leaves auth endpoints exposed. Limits must match endpoint sensitivity.
  • Limiting only at the edge — a compromised or buggy internal service bypasses the gateway. Keep app-layer limits too.
  • Never expiring keys — sliding-window timestamps and counters accumulate forever. Every key needs a TTL.

Further Reading

See Also

Frequently Asked Questions

Can an attacker just rotate IPs to bypass rate limiting?

Only if the key is IP alone — that fails against botnets and proxy rotation. Combine IP with account identifier, fingerprint, or token: rotation then costs fresh credentials instead of fresh IPs, which is far more expensive.

Does rate limiting stop DDoS?

It helps against L7 floods — requests that look like real traffic but arrive too fast. Volumetric L3/L4 attacks need upstream filtering (CDN, scrubbing centers) before the traffic reaches your servers at all.

What's the difference between rate limiting, throttling, and a WAF?

Rate limiting rejects requests past a threshold (429). Throttling slows them down instead. A WAF inspects content — payloads, signatures, patterns. They're complementary: WAF for what requests contain, rate limiting for how many arrive.

Should authenticated and anonymous users get different limits?

Yes. Authenticated users get keyed limits by user ID — fairer and harder to game. Anonymous traffic gets IP-based limits with lower ceilings, since it's the highest-abuse-risk group.

What should I log when a limit trips?

Which key tripped it, the endpoint, the timestamp, and whether it's a repeat offender. Feed 429s into your SIEM and the limiter doubles as a detection sensor — a client that keeps hitting the wall after being told to back off is telling you something.