Why limit, where, and per what
A rate limit is like the rule at a busy bakery: “max 4 loaves per customer”. It doesn’t stop anyone from shopping; it stops one customer from emptying the shelves for everyone else.
Systems use rate limits for four reasons:
- Protection: a server can handle a certain load; beyond that, it slows down for everyone.
- Fairness: one heavy user shouldn’t make the service slow for all the others.
- Cost: some requests cost money (SMS, AI models, paid APIs).
- Abuse: guessing passwords, scraping, spamming sign-ups.
Limits can live in several places, from the outside in:
- The edge (CDN or load balancer): rough limits per IP address, to stop floods before they reach your servers.
- The API gateway: precise limits per user, API key or endpoint (“100 searches per minute per user”).
- Inside a service: limits on especially expensive operations.
- On your own outbound calls: if a payment provider allows 100 requests per second, your code must not send more.
Rate limiting is admission control by request rate per key over time. Layering matters: edge limits (CDN/WAF, per IP or ASN) absorb volumetric abuse cheaply but can’t see identity; gateway limits key on authenticated identity (user, API key, tenant) and endpoint class, and implement product tiers (“free: 60 req/min, pro: 600”); service-level limits guard specific expensive operations and dependencies; outbound limits respect third-party quotas, ideally shared across instances. Key choice is the crux: per-IP limits punish users behind carrier-grade NAT and corporate proxies (thousands of users, one IP) and are trivially evaded with IPv6 ranges, while per-user limits only work after authentication, so login and sign-up endpoints need IP plus account-based keys. Behind proxies, the client IP must come from a header set by your trusted proxy (X-Forwarded-For processed from the right, or the load balancer’s own header), never from a value the client can set.
// Spring Cloud Gateway: a Redis-backed token bucket per user, per route
@Bean
fun routes(builder: RouteLocatorBuilder, userKey: KeyResolver): RouteLocator = builder.routes()
.route("search") { r ->
r.path("/api/search/**").filters { f ->
f.requestRateLimiter { c ->
c.rateLimiter = RedisRateLimiter(10, 20, 1) // 10/s refill, bursts up to 20
c.keyResolver = userKey
}
}.uri("lb://search-service")
}
.build()
@Bean
fun userKey(): KeyResolver = KeyResolver { exchange ->
exchange.getPrincipal<Principal>().map { it.name } // authenticated user, not IP
}// Spring Cloud Gateway: a Redis-backed token bucket per user, per route
@Bean
RouteLocator routes(RouteLocatorBuilder builder, KeyResolver userKey) {
return builder.routes()
.route("search", r -> r.path("/api/search/**")
.filters(f -> f.requestRateLimiter(c -> {
c.setRateLimiter(new RedisRateLimiter(10, 20, 1)); // 10/s refill, bursts up to 20
c.setKeyResolver(userKey);
}))
.uri("lb://search-service"))
.build();
}
@Bean
KeyResolver userKey() {
return exchange -> exchange.getPrincipal().map(Principal::getName); // authenticated user
}// Express: per-user limit on search, backed by Redis so all instances share counters
import { rateLimit } from "express-rate-limit";
import { RedisStore } from "rate-limit-redis";
const searchLimiter = rateLimit({
windowMs: 60_000,
limit: 100, // 100 searches per minute
keyGenerator: (req) => req.user!.id, // authenticated user, not IP
standardHeaders: "draft-7", // RateLimit headers for clients
store: new RedisStore({ sendCommand: (...args: string[]) => redis.sendCommand(args) }),
});
app.use("/api/search", requireAuth, searchLimiter, searchRouter);Tap the line with the bug.
@Component
class LoginRateLimitFilter(private val limiter: Limiter) : OncePerRequestFilter() {
override fun doFilterInternal(req: HttpServletRequest, res: HttpServletResponse, chain: FilterChain) {
val key = "login:" + req.remoteAddr
if (!limiter.tryAcquire(key, perMinute = 10)) {
res.sendError(429); return
}
chain.doFilter(req, res)
}
}
// The service runs behind a load balancer@Component
public class LoginRateLimitFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res, FilterChain chain)
throws ServletException, IOException {
String key = "login:" + req.getRemoteAddr();
if (!limiter.tryAcquire(key, 10)) { res.sendError(429); return; }
chain.doFilter(req, res);
}
}
// The service runs behind a load balancerconst loginLimiter = rateLimit({
windowMs: 60_000,
limit: 10,
keyGenerator: (req) => "login:" + req.socket.remoteAddress,
});
app.post("/login", loginLimiter, loginHandler);
// The service runs behind a load balancerHint: Behind a load balancer, which machine opened the connection to this server?
The connection’s address is the load balancer’s, not the user’s. Every login attempt from every user has the same key, so after 10 logins per minute in total, everyone gets 429. On a quiet day nobody notices; on Monday morning nobody can log in.
Take the client IP from the header your proxy sets, and only trust it from your proxy. For logins, also limit per account, so an attacker spreading guesses across many IPs is still slowed down:
// application.yml: server.forward-headers-strategy: native (or framework)
// so req.remoteAddr is the client IP taken from the trusted proxy's X-Forwarded-For
val ipKey = "login:ip:" + req.remoteAddr
val accountKey = "login:account:" + req.getParameter("email").lowercase()
if (!limiter.tryAcquire(ipKey, perMinute = 20) || !limiter.tryAcquire(accountKey, perMinute = 5)) {
res.sendError(429); return
}// application.yml: server.forward-headers-strategy: native (or framework)
String ipKey = "login:ip:" + req.getRemoteAddr();
String accountKey = "login:account:" + req.getParameter("email").toLowerCase();
if (!limiter.tryAcquire(ipKey, 20) || !limiter.tryAcquire(accountKey, 5)) { res.sendError(429); return; }app.set("trust proxy", 1); // trust exactly one hop: our load balancer; req.ip is now the client
const loginLimiter = rateLimit({
windowMs: 60_000,
limit: 20,
keyGenerator: (req) => "login:ip:" + req.ip,
});
const accountLimiter = rateLimit({
windowMs: 60_000,
limit: 5,
keyGenerator: (req) => "login:account:" + String(req.body.email).toLowerCase(),
});
app.post("/login", loginLimiter, accountLimiter, loginHandler);Trusting X-Forwarded-For from anyone is the opposite mistake: an attacker sends a different fake IP in each request and is never limited.
Fixed window count per clock minute simple; up to 2× bursts at window edges
Sliding log timestamps of every request exact; memory grows with the limit
Sliding window two windows, weighted close to exact; two counters per key
Token bucket tokens refill at a steady rate allows bursts up to the bucket size; very common
Leaky bucket a queue drained at a fixed rate smooths output; adds waiting instead of rejectingThe token bucket is the one you’ll see most: each user has a bucket of, say, 20 tokens that refills at 10 tokens per second. Each request takes a token; when the bucket is empty, requests are rejected until it refills. It allows short bursts (opening an app fires several requests at once) while keeping the average rate in check. The Design a rate limiter lesson implements it.
- Purposes: capacity protection, fairness, cost control, abuse mitigation.
- Layers: edge (per IP/ASN, volumetric), gateway (per user/key/tenant and endpoint, product tiers), service (expensive ops), outbound (third-party quotas, shared across instances).
- Keys: per-IP hurts NAT/proxy users and is evadable; per-user needs auth; login/sign-up need IP + account keys.
- Client IP only from your trusted proxy (
forward-headers-strategy,trust proxyhop count); never trust arbitraryX-Forwarded-For. - Algorithms: fixed window (edge bursts), sliding log/window, token bucket (bursty, common), leaky bucket (smoothing).
Check this part
Which is NOT a usual reason for rate limiting?
Why does a token bucket suit mobile apps well?
Behind a load balancer, keying a limit on the connection's remote address…