RE:NODE

Security15 min read

Rate limiting: where to put limits and how to size them

Limits are not about capacity. Where to put them, which algorithm to use, what to key them on, how to return 429 properly, and what they cannot stop.

Updated

0 readers

A rate limit is usually explained as protection against load, which undersells it. Ordinary traffic does not need limiting. The limit exists for the single client that will make ten thousand requests a minute, whether through malice or through a loop somebody wrote by accident, and for the attacker working through a list of leaked passwords one account at a time. Those two are the whole audience.

A limit is four decisions: what you count, what you count it against, over what window, and what you do when the count is exceeded. Get the second one wrong and the limit either does nothing or locks out an entire office. This post covers all four, the algorithms behind them, the nginx and application configuration that implements them, the numbers worth starting with, and the attacks a rate limit cannot touch.

Where limits belong, in the order you should add them#

You do not need a limit on every route. You need one on the routes where a single client can cost you money, reputation or an account. In rough order of how much trouble they cause when unlimited:

  1. Login. This is where credential stuffing arrives: a botnet replaying leaked email and password pairs against your form. Unlimited, it is free for the attacker.
  2. Anything that sends an email, an SMS or a push. Password resets, invites, contact forms. Each one costs money, and a few thousand of them get your sending domain blocked.
  3. Registration. Unless you enjoy moderating accounts nobody created on purpose, and cleaning up the content they post.
  4. Expensive reads. Search, exports, reports, anything that scans a table or renders a PDF. One client in a loop here is indistinguishable from an outage.
  5. Write endpoints in general. Comments, uploads, messages. Spam is a rate problem before it is a content problem.
  6. Everything else, as a wide backstop, so a runaway script hits something before it hits your database.

A table of starting numbers, for an ordinary web application with a few thousand users. They are deliberately generous - the point of the first limit is to catch abuse, not to police enthusiasm.

EndpointStarting limitKeyed on
POST /login5 failed attempts per 15 minutesIP and username together
POST /register3 per hourIP
Password reset, invite, contact3 per hourAccount, then IP
Search, export, report10 per minuteAccount
Public read API60 per minuteAPI key
Everything else300 per minuteIP

Note the first row counts failures, not requests. A user who logs in correctly twenty times has done nothing wrong; a client that fails five times and keeps going is guessing. Counting failures also means a successful login can reset the counter, which removes most of the false positives.

The four algorithms, and which one to use#

Every limiter is one of these, whatever the library calls it.

Fixed window. Count requests per key per calendar minute; reset to zero on the minute. One counter, one expiry, almost free to store. Its flaw is the boundary: a client can send the full allowance at 10:59:59 and the full allowance again at 11:00:00, so the real worst case is double the number you configured. For login limits that is irrelevant. For a paid API where the number is a promise, it is not.

Sliding window log. Store the timestamp of every request and count the ones inside the last N seconds. Exact, and the memory grows with the traffic you are trying to limit, which is the wrong direction. Fine for a login counter with five entries, wrong for a global limit.

Sliding window counter. Keep the current window's count and the previous window's, and weight the previous one by how far into the current window you are. Two counters per key, no boundary burst worth worrying about, accuracy within a percent or two. This is what most CDNs and most good libraries actually do, and it is the sensible default.

Token bucket. A bucket holds up to N tokens and refills at r tokens per second. Each request takes one. An idle client accumulates up to N and can spend them at once; a busy one settles at r. This is the only algorithm that expresses the thing you usually want, which is "sixty a minute, but I do not mind ten at once". Its cousin the leaky bucket does the same arithmetic but queues the excess instead of rejecting it, smoothing traffic rather than refusing it.

Token bucket for APIs, sliding window counter for general protection, fixed window for login counters. That covers every case a small service has.

Choosing the key, which is harder than choosing the number#

The key is what you count against, and it decides whether your limit is fair.

  • IP address is the only key available before authentication, so login and registration limits have to use it. It punishes shared addresses: an office, a school, a mobile carrier behind CGNAT, and a whole country's worth of traffic behind some corporate proxies. Keep the numbers generous and count failures rather than requests.
  • IPv6 needs a prefix, not an address. A single customer is usually handed a /64, often a /56 or /48. Limiting per /128 means an attacker with one connection has effectively unlimited keys. Truncate to the /64 before you hash it.
  • User or account ID is fair and precise, but only exists after authentication. Use it for everything behind a login.
  • API key is the right unit for an API, because it is also the unit you bill and the unit you can revoke.
  • Combinations. Login is best limited on three counters at once: per IP, per username, and per IP-and-username pair. The pair catches the ordinary attacker, the per-username counter catches a distributed attack on one account, and the per-IP counter catches a scan across many accounts.

The failure mode that makes rate limiting silently useless is the reverse proxy. If your application sits behind a proxy, a load balancer or a CDN, the connecting address it sees is the proxy's, so every visitor in the world shares one key. The real address arrives in X-Forwarded-For, and your framework has to be told to read it. The same header is also the easiest header in the world to forge, so you must only trust it when the connection came from a proxy you actually operate.

javascript
// Express: trust exactly one proxy hop, not "true"app.set("trust proxy", 1);
nginx
# nginx: replace $remote_addr with the last untrusted hopset_real_ip_from 10.0.0.0/8;real_ip_header X-Forwarded-For;real_ip_recursive on;

On RE:NODE, app and web plans include a proxy slot, and the real client address arrives in X-Forwarded-For - so both settings above apply to anything you deploy there. What a reverse proxy does explains the rest of the headers that change on the way through.

floods and scanswhat survivesreal client IPincrement and expire429 and Retry-AfterUpstream filteringvolumetric floodsProxy slotX-Forwarded-ForYour appper-key counterShared counterRedis or databaseClientone IP, one token
Where a request meets each kind of limit

Returning 429 properly#

Dropping the connection is the worst response available. A well-behaved client cannot tell a limit from an outage and retries immediately; a badly behaved one was going to retry anyway; and you have no log line saying why.

The correct answer is 429 Too Many Requests with a Retry-After header, a short body the client can parse, and a log entry. Retry-After takes either a number of seconds or an HTTP date.

code
HTTP/1.1 429 Too Many RequestsRetry-After: 30Content-Type: application/json{"error":"rate_limited","retry_after":30}

Two more things worth getting right:

  • Tell clients where they stand before they hit the wall. The long-standing convention is X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset on every response, not only the rejected ones. An IETF draft standardises the same idea under RateLimit-* names, but it has changed shape more than once, so pick one set, document it, and do not change it without notice.
  • Add jitter to the reset. If a thousand clients are told to retry in exactly thirty seconds, they all return in the same millisecond. Vary the value you send by a few per cent per client, and tell your own clients to back off exponentially with jitter.

Use 429 when the client is at fault and 503 Service Unavailable when you are. The distinction matters because monitoring, clients and search engine crawlers all treat them differently. A 429 at the login endpoint is your system working; a 503 is your system failing.

Rate limiting in nginx#

If there is a web server in front of your application, the cheapest limit in the stack is the one that never reaches your code. nginx implements a leaky bucket in limit_req and a straight concurrency cap in limit_conn.

nginx
# Shared memory zones live in the http block. 1 MB holds roughly# 16,000 states keyed by $binary_remote_addr.limit_req_zone  $binary_remote_addr zone=general:10m rate=10r/s;limit_req_zone  $binary_remote_addr zone=login:10m   rate=10r/m;limit_conn_zone $binary_remote_addr zone=conns:10m;server {    limit_req_status  429;    limit_conn_status 429;    location / {        limit_req zone=general burst=20 nodelay;        limit_conn conns 20;    }    location = /login {        limit_req zone=login burst=5;    }}

Points that catch people out:

  • The default rejection status is 503, not 429. Set limit_req_status 429 or your monitoring will tell you the site is down every time somebody runs a scraper.
  • rate=10r/s is enforced at millisecond granularity: it means one request every 100 ms, not ten at the top of each second. Without burst, the eleventh request in a second is rejected even though the second is not over.
  • burst=20 allows twenty queued requests above the rate. Without nodelay they are delayed to fit the rate, which is what you want on a login form. With nodelay they are served at once and only then counted, which is what you want on an API.
  • Rejections are logged at error level by default. Turn that down with limit_req_log_level notice if the noise buries real errors, but do not turn it off - the log is how you find out which key is misbehaving. Logs worth keeping covers what to do with them afterwards.
  • limit_conn is a different tool for a different abuse: a client that opens hundreds of slow connections and holds them. Twenty per address is plenty for a browser.

Rate limiting in your application#

Anything that needs to know about users, accounts or failures belongs in the application, where the state is.

javascript
// Express, with express-rate-limitimport rateLimit from "express-rate-limit";const loginLimiter = rateLimit({  windowMs: 15 * 60 * 1000,  limit: 5,                       // called max in older versions  skipSuccessfulRequests: true,   // count failures, not attempts  standardHeaders: true,  legacyHeaders: false,});app.post("/login", loginLimiter, handler);
python
# Django REST framework: rates in settings, throttles per viewREST_FRAMEWORK = {    "DEFAULT_THROTTLE_CLASSES": [        "rest_framework.throttling.AnonRateThrottle",        "rest_framework.throttling.UserRateThrottle",    ],    "DEFAULT_THROTTLE_RATES": {"anon": "60/minute", "user": "600/hour"},}

The detail that decides whether any of this works is where the counter lives. Every one of these libraries defaults to an in-process store, which means each worker keeps its own count. Four Node processes with a limit of five is a limit of twenty, and a restart resets it. For a single process on a single server that is fine and you should not add anything. For more than one process, the counter has to be shared - a Redis INCR with an expiry is the standard answer, and Redis, and whether you need it yet covers running one without buying a second problem. Your existing database works too, at the cost of a write per request.

For the login counter specifically, an even simpler option exists: store failed_attempts and locked_until on the user row you are already reading to check the password. No new service, no new failure mode, and it survives a restart.

Abuse that is not HTTP#

Game servers get the same treatment from a different direction, and most of it is not something you write code for.

  • Query ports. The small UDP port next to a game port answers "what map, how many players" to anyone who asks. Because the answer is larger than the question, query protocols have been used for reflection attacks, with your server as the amplifier and someone else as the victim. Source-engine servers now expect a challenge before answering A2S_INFO on current builds. The practical advice is to run current versions and not to allocate a query port you do not use - game server ports explained lists which games need which.
  • Join floods. Bots connecting and disconnecting in a loop cost real CPU in the authentication path. Minecraft has a built-in packet limiter: rate-limit in server.properties sets the packets per second a single connection may send before it is kicked, and 0 disables it. Paper adds separate spam limiters for tab completion and recipe requests.
  • RCON. Most implementations have no limit and no lockout at all, which makes the password the only thing between an attacker and your console. Do not expose it to the internet, and read using RCON safely before you do anything else with it.
  • SSH and admin panels. This is what fail2ban is for: it watches a log, and bans an address at the firewall after N failures in M minutes. It is a rate limiter that happens to be written in iptables rules. A fail2ban guide covers the jails worth enabling, and firewall rules that matter covers what should be reachable at all.

The panel side of RE:NODE is limited in the same way: login has captcha and rate limits in front of it, passwords are stored as bcrypt hashes, API keys can be restricted to specific addresses, and every session is listed so you can sign the others out. Turning on two-factor authentication removes the category entirely for your own account.

What a rate limit cannot do#

Being honest about the ceiling is the point of having one.

  • Volumetric floods. If the uplink is full, your limiter never runs, because the packets carrying the requests never arrive. That is an upstream problem and it has to be solved upstream. On RE:NODE the wording is deliberately narrow: upstream filtering that drops obvious volumetric floods, reflection and malformed traffic. Attacks that look like real players are not filtered, because nothing can tell them apart without also dropping real players. What we do about attacks and DDoS attacks on game servers explained go through what is and is not possible here.
  • Distributed low-and-slow abuse. Ten thousand addresses making one request a second each is a busy afternoon to a per-IP limiter and an outage to your database. Defending against it means behavioural signals, not counters.
  • Making a slow endpoint fast. A limit stops a slow endpoint taking everything else down with it. It does not stop it being slow, and using one as a substitute for an index is how you end up with both problems.
  • Protecting you from yourself. Your own cron jobs, retry loops and health checks bypass every limit you wrote, because they are inside. Budget for them separately.

One more cost worth stating: the limiter itself. A check that costs a network round trip to a shared store adds that round trip to every request, including the overwhelming majority that are fine. Keep the hot path local where you can - an in-process token bucket for the wide backstop, the shared store only for the endpoints where exactness matters.

Picking the numbers and proving them#

Do not guess twice. Measure once, then set the limit above what real users do.

  1. Look at what happens now. Take a week of access logs and find the 99th percentile of requests per minute per address, and the busiest legitimate client you can identify. Your limit goes comfortably above that number.
  2. Start in observe mode. Log what would have been rejected without rejecting it. Run it for a few days. Almost every first attempt catches something you did not expect - a mobile app that polls, a partner integration, your own monitoring.
  3. Then enforce, and watch the rejections. A steady trickle of 429s is healthy. A spike is either an attack or a client you just broke, and the log line tells you which because it contains the key.
  4. Test it deliberately. Send the limit plus one request from a test address and confirm you get a 429, a Retry-After, and a log entry. Confirm from a second address that you are not globally limited, which is the classic symptom of a misconfigured proxy header.
  5. Alert on the ratio, not the count. Rejections as a share of requests is a number that means the same thing at every traffic level. Monitoring that tells you something is about picking metrics that behave like this.

Review the numbers when the shape of your traffic changes - a launch, a new mobile client, an integration partner - and write down why each limit is what it is. A limit with no stated reason gets doubled by the next person who sees a 429 in a log.

FAQ#

What status code should a rate-limited request return?

429 Too Many Requests, with a Retry-After header saying how long to wait. Use 503 only when the fault is yours. nginx returns 503 for limit_req by default, so set limit_req_status 429 explicitly.

Should I rate limit by IP address or by user?

By user wherever a user exists, because it is fair and precise. By IP where one does not - login, registration, public endpoints - with generous numbers, because addresses are shared. For login, run both, plus a counter on the username, and count failures rather than attempts.

Why does my rate limiter block everyone at once?

Almost always because your application is behind a proxy and sees the proxy's address for every request, so every visitor shares one counter. Configure the trusted proxy setting in your framework and read X-Forwarded-For - but only trust that header from addresses you control.

Do rate limits stop a DDoS attack?

No. A limit protects the work behind it once a request has arrived. A volumetric flood fills the connection before your code runs, so it has to be dropped upstream. Limits stop the abuse that arrives at normal speed: credential stuffing, scraping, spam and runaway clients.

Where should the counter live if I run several processes?

Somewhere all of them can see. Redis with INCR and an expiry is the usual choice; your existing database works at the cost of a write per request. An in-process counter is correct only for a single process, and it silently multiplies your limit by the number of workers otherwise.

How do I rate limit a game server?

Mostly you do not, in code. You avoid exposing query and RCON ports you do not need, keep the server build current so its own protections are in place, use the game's own packet limiter where it has one, and rely on upstream filtering for floods. Application-style limits belong to the web things around the server, not the game protocol.


Comments

Completely anonymous: no account, no email, no cookie. We store the name you type, the text and the time - nothing else. Links are limited and markup is not rendered.

0/2000