{"course":{"slug":"cloud-and-shipping","title":"Shipping It","url":"https://addaly.com/learn/cloud-and-shipping","level":"intermediate","track":"build"},"lesson":{"slug":"rate-limits-and-abuse","title":"The traffic that is not customers","url":"https://addaly.com/learn/cloud-and-shipping/rate-limits-and-abuse","position":36,"of":74,"minutes":9,"answer":"Limit per account and per endpoint rather than only per IP address, and put the tightest limits on whatever spends money per request.","keyIdea":"Limit per account and per endpoint rather than only per IP address, and put the tightest limits on whatever spends money per request."},"body":{"format":"markdown","text":"## What arrives once you are public\n\nWithin hours of a public IP existing, it is being scanned. Within days of a domain resolving, forms are being submitted by things that are not people. The mix is consistent:\n\n- Scanners probing for `/wp-admin`, `/.env`, `/.git/config` and a few thousand other paths. `/.env` is the one that matters: an exposed environment file is a complete compromise, and it is checked because it works.\n- Credential stuffing: lists of leaked email and password pairs from other breaches, tried against your login form.\n- Signup spam, to use your service, your storage, or your outbound email as a relay.\n- Scrapers and crawlers, including a large and growing volume of AI training crawlers, some of which ignore `robots.txt` entirely.\n- Somebody's misconfigured client retrying forty times a second because their first request failed.\n\nNone of this is targeted. You do not need to be interesting.\n\n## Token bucket, and why fixed windows leak\n\nA **fixed window** (100 requests per minute) allows 200 requests across a two-second boundary: 100 at 11:59:59 and 100 at 12:00:00. For a limit protecting an expensive resource, that doubling matters.\n\nA **token bucket** holds N tokens, refilled at a steady rate. Each request takes one. It permits a burst up to the bucket size and then enforces the long-run rate. It is what you want, it is a few lines of code, and it is what this project's rate limiter uses.\n\n## Per-IP is not enough on its own\n\nLayer three limits, because each one fails differently:\n\n- **Per IP**: catches crude floods. But carrier-grade NAT means an entire city can share an address, a university looks like one client, and behind a CDN every request appears to come from the CDN unless you read the forwarded header correctly. Keep this limit loose, or you will block real people in blocks.\n- **Per account**: the accurate one for anything a signed-in user does. This is where the real limits belong.\n- **Per endpoint**: because `/login` and `/search` and `/api/generate-image` have nothing in common. One should tolerate thousands of requests; one should tolerate five failures in a row.\n\nAnd be exact about the expensive endpoints, because rate limits protect the bill as much as the box. Anything that calls a paid API, sends an email or SMS, generates an image, or runs a model needs a hard per-account daily cap, not just a rate. That is what this project's `ai_usage` table does: ten AI chats per person per day during the beta, claimed last and refunded if the provider fails.\n\n## Answer with 429, and say when\n\n```\nHTTP/1.1 429 Too Many Requests\nRetry-After: 30\n```\n\nA 429 with no `Retry-After` teaches every well-behaved client to retry immediately, which is the opposite of what you asked for. Include it.\n\nDo not use 403 for rate limiting. A client needs to distinguish \"you are going too fast\" from \"you are not allowed\", and so does the person reading your logs at 2am.\n\n## Fail open or fail closed?\n\nWhen the limiter itself is unavailable (the store is down, the timeout fires) you must choose in advance.\n\nThe reasonable default is to **fail open for authenticated, trusted users** and **fail closed for anonymous writes**. Losing the limiter should not be an outage for the people you know; it also should not be an open door for the people you do not. This is the same principle the moderation pipeline in this project uses when a classifier tier is degraded: keep trusted members working, hold the untrusted.\n\n> Figure: When the limiter itself is down. A two by two grid, rows Signed-in, trusted account and Anonymous, columns Read and Write or spend money: Signed-in, trusted account and Read: Let through; Signed-in, trusted account and Write or spend money: Let through; Anonymous and Read: Let through; Anonymous and Write or spend money: Hold. Losing the rate limiter should not be an outage for the people you know, and it should not be an open door for the people you do not. Decide this on a calm afternoon and write it down.\n\nWhatever you choose, write it down. The default is usually \"fail open\" because nobody decided, and that is worth knowing.\n\n## Signup abuse specifically\n\nEmail verification before anything expensive. **Cloudflare Turnstile** is free and does not make users identify traffic lights; **hCaptcha** has a free tier. Block disposable email domains if it fits your product, and know that the lists are never complete.\n\nOne specific warning, because it costs real money: **do not put SMS OTP on an open signup form.** SMS pumping (fraudulent traffic to premium-rate numbers, where the attacker takes a share of the carrier revenue) has cost companies seven-figure sums. If you need SMS, rate-limit it per number and per IP, restrict country codes to those you serve, and cap the daily spend at the provider.\n\n## The free path\n\nCloudflare's free plan gives you basic WAF rules, bot-fight mode and a small number of rate-limiting rules at the edge, which stops the traffic before it reaches your bill. `nginx`'s `limit_req` is free. `fail2ban` on a VM reads your logs and blocks repeat offenders at the firewall.\n\nAnd put `/.env`, `/.git` and `/wp-admin` in a deny rule. They will never be legitimate, and blocking them at the edge removes a large share of your log noise on day one."},"check":{"question":"You add a rate limit of 100 requests per minute per IP address. Complaints arrive from users in one city who are being blocked while browsing normally, and separately your login endpoint is still being brute-forced. What is going on?","options":[{"text":"Carrier-grade NAT puts many real users behind one address while the attacker rotates addresses, so a per-IP limit punishes the wrong people and misses the attack","correct":true},{"text":"The limit is too low, and raising it to 1,000 per minute resolves both problems","correct":false,"tempting":"Raising it helps the blocked users and helps the attacker equally; the shape of the limit is wrong, not its value."},{"text":"The rate limiter is using a fixed window, allowing double the intended rate at boundaries","correct":false,"tempting":"A genuine defect worth fixing, but a factor of two does not explain either a blocked city or a sustained brute-force."},{"text":"The login endpoint is excluded from the limiter by a route-matching bug","correct":false,"tempting":"Possible, and worth checking, but it explains only half of what was observed and leaves the blocked users unaccounted for."}]},"prev":{"slug":"queues-and-background-work","title":"Work that does not belong in a request","url":"https://addaly.com/learn/cloud-and-shipping/queues-and-background-work"},"next":{"slug":"uploads-and-object-storage","title":"Files people send you","url":"https://addaly.com/learn/cloud-and-shipping/uploads-and-object-storage"},"authorship":"Founder and series editor: Dr Mustafa Mun. Written by AI models to a written standard. Exam keys checked blind by a second model: 3,665 of 3,666 agreed. Academic review invited.","review":{"status":"Draft, open for academic review"},"cite":"Addaly (2026) 'The traffic that is not customers', in Shipping It. Edited by M. Mun. Addaly. Available at: https://addaly.com/learn/cloud-and-shipping/rate-limits-and-abuse (Accessed: 13 October 2026).","licence":{"name":"CC BY-NC-SA 4.0","url":"https://creativecommons.org/licenses/by-nc-sa/4.0/"},"reader":{"schema":1,"title":"The traffic that is not customers","courseTitle":"Shipping It","module":{"index":4,"slug":"load-cost-and-abuse","title":"Load, cost and the traffic that is not customers"},"answer":null,"keyIdea":"Limit per account and per endpoint rather than only per IP address, and put the tightest limits on whatever spends money per request.","hero":{"kind":"figure","figure":{"t":"figure","label":"Figure","figure":{"cols":["Read","Write or spend money"],"rows":["Signed-in, trusted account","Anonymous"],"type":"matrix","cells":[["Let through","Let through"],["Let through","Hold"]],"title":"When the limiter itself is down","caption":"Losing the rate limiter should not be an outage for the people you know, and it should not be an open door for the people you do not. Decide this on a calm afternoon and write it down.","cellLabels":[["fail open","fail open"],["fail open","fail closed"]]},"alt":"Figure: When the limiter itself is down. A two by two grid, rows Signed-in, trusted account and Anonymous, columns Read and Write or spend money: Signed-in, trusted account and Read: Let through; Signed-in, trusted account and Write or spend money: Let through; Anonymous and Read: Let through; Anonymous and Write or spend money: Hold. Losing the rate limiter should not be an outage for the people you know, and it should not be an open door for the people you do not. Decide this on a calm afternoon and write it down."}},"blocks":[{"t":"h2","id":"what-arrives-once-you-are-public","runs":[{"text":"What arrives once you are public"}]},{"t":"p","runs":[{"text":"Within hours of a public IP existing, it is being scanned. Within days of a domain resolving, forms are being submitted by things that are not people. The mix is consistent:"}]},{"t":"ul","items":[[{"text":"Scanners probing for "},{"text":"/wp-admin","code":true},{"text":", "},{"text":"/.env","code":true},{"text":", "},{"text":"/.git/config","code":true},{"text":" and a few thousand other paths. "},{"text":"/.env","code":true},{"text":" is the one that matters: an exposed environment file is a complete compromise, and it is checked because it works."}],[{"text":"Credential stuffing: lists of leaked email and password pairs from other breaches, tried against your login form."}],[{"text":"Signup spam, to use your service, your storage, or your outbound email as a relay."}],[{"text":"Scrapers and crawlers, including a large and growing volume of AI training crawlers, some of which ignore "},{"text":"robots.txt","code":true},{"text":" entirely."}],[{"text":"Somebody’s misconfigured client retrying forty times a second because their first request failed."}]]},{"t":"p","runs":[{"text":"None of this is targeted. You do not need to be interesting."}]},{"t":"h2","id":"token-bucket-and-why-fixed-windows-leak","runs":[{"text":"Token bucket, and why fixed windows leak"}]},{"t":"p","runs":[{"text":"A "},{"text":"fixed window","b":true},{"text":" (100 requests per minute) allows 200 requests across a two-second boundary: 100 at 11:59:59 and 100 at 12:00:00. For a limit protecting an expensive resource, that doubling matters."}]},{"t":"p","runs":[{"text":"A "},{"text":"token bucket","b":true},{"text":" holds N tokens, refilled at a steady rate. Each request takes one. It permits a burst up to the bucket size and then enforces the long-run rate. It is what you want, it is a few lines of code, and it is what this project’s rate limiter uses."}]},{"t":"h2","id":"per-ip-is-not-enough-on-its-own","runs":[{"text":"Per-IP is not enough on its own"}]},{"t":"p","runs":[{"text":"Layer three limits, because each one fails differently:"}]},{"t":"ul","items":[[{"text":"Per IP","b":true},{"text":": catches crude floods. But carrier-grade NAT means an entire city can share an address, a university looks like one client, and behind a CDN every request appears to come from the CDN unless you read the forwarded header correctly. Keep this limit loose, or you will block real people in blocks."}],[{"text":"Per account","b":true},{"text":": the accurate one for anything a signed-in user does. This is where the real limits belong."}],[{"text":"Per endpoint","b":true},{"text":": because "},{"text":"/login","code":true},{"text":" and "},{"text":"/search","code":true},{"text":" and "},{"text":"/api/generate-image","code":true},{"text":" have nothing in common. One should tolerate thousands of requests; one should tolerate five failures in a row."}]]},{"t":"p","runs":[{"text":"And be exact about the expensive endpoints, because rate limits protect the bill as much as the box. Anything that calls a paid API, sends an email or SMS, generates an image, or runs a model needs a hard per-account daily cap, not just a rate. That is what this project’s "},{"text":"ai_usage","code":true},{"text":" table does: ten AI chats per person per day during the beta, claimed last and refunded if the provider fails."}]},{"t":"h2","id":"answer-with-429-and-say-when","runs":[{"text":"Answer with 429, and say when"}]},{"t":"code","lang":"","label":"Text","text":"HTTP/1.1 429 Too Many Requests\nRetry-After: 30"},{"t":"p","runs":[{"text":"A 429 with no "},{"text":"Retry-After","code":true},{"text":" teaches every well-behaved client to retry immediately, which is the opposite of what you asked for. Include it."}]},{"t":"p","runs":[{"text":"Do not use 403 for rate limiting. A client needs to distinguish “you are going too fast” from “you are not allowed”, and so does the person reading your logs at 2am."}]},{"t":"h2","id":"fail-open-or-fail-closed","runs":[{"text":"Fail open or fail closed?"}]},{"t":"p","runs":[{"text":"When the limiter itself is unavailable (the store is down, the timeout fires) you must choose in advance."}]},{"t":"p","runs":[{"text":"The reasonable default is to "},{"text":"fail open for authenticated, trusted users","b":true},{"text":" and "},{"text":"fail closed for anonymous writes","b":true},{"text":". Losing the limiter should not be an outage for the people you know; it also should not be an open door for the people you do not. This is the same principle the moderation pipeline in this project uses when a classifier tier is degraded: keep trusted members working, hold the untrusted."}]},{"t":"p","runs":[{"text":"Whatever you choose, write it down. The default is usually “fail open” because nobody decided, and that is worth knowing."}]},{"t":"h2","id":"signup-abuse-specifically","runs":[{"text":"Signup abuse specifically"}]},{"t":"p","runs":[{"text":"Email verification before anything expensive. "},{"text":"Cloudflare Turnstile","b":true},{"text":" is free and does not make users identify traffic lights; "},{"text":"hCaptcha","b":true},{"text":" has a free tier. Block disposable email domains if it fits your product, and know that the lists are never complete."}]},{"t":"p","runs":[{"text":"One specific warning, because it costs real money: "},{"text":"do not put SMS OTP on an open signup form.","b":true},{"text":" SMS pumping (fraudulent traffic to premium-rate numbers, where the attacker takes a share of the carrier revenue) has cost companies seven-figure sums. If you need SMS, rate-limit it per number and per IP, restrict country codes to those you serve, and cap the daily spend at the provider."}]},{"t":"h2","id":"the-free-path","runs":[{"text":"The free path"}],"role":"free"},{"t":"p","runs":[{"text":"Cloudflare’s free plan gives you basic WAF rules, bot-fight mode and a small number of rate-limiting rules at the edge, which stops the traffic before it reaches your bill. "},{"text":"nginx","code":true},{"text":"’s "},{"text":"limit_req","code":true},{"text":" is free. "},{"text":"fail2ban","code":true},{"text":" on a VM reads your logs and blocks repeat offenders at the firewall."}]},{"t":"p","runs":[{"text":"And put "},{"text":"/.env","code":true},{"text":", "},{"text":"/.git","code":true},{"text":" and "},{"text":"/wp-admin","code":true},{"text":" in a deny rule. They will never be legitimate, and blocking them at the edge removes a large share of your log noise on day one."}]}],"check":{"question":[{"text":"You add a rate limit of 100 requests per minute per IP address. Complaints arrive from users in one city who are being blocked while browsing normally, and separately your login endpoint is still being brute-forced. What is going on?"}],"options":[{"text":[{"text":"Carrier-grade NAT puts many real users behind one address while the attacker rotates addresses, so a per-IP limit punishes the wrong people and misses the attack"}],"correct":true,"tempting":null},{"text":[{"text":"The limit is too low, and raising it to 1,000 per minute resolves both problems"}],"correct":false,"tempting":[{"text":"Raising it helps the blocked users and helps the attacker equally; the shape of the limit is wrong, not its value."}]},{"text":[{"text":"The rate limiter is using a fixed window, allowing double the intended rate at boundaries"}],"correct":false,"tempting":[{"text":"A genuine defect worth fixing, but a factor of two does not explain either a blocked city or a sustained brute-force."}]},{"text":[{"text":"The login endpoint is excluded from the limiter by a route-matching bug"}],"correct":false,"tempting":[{"text":"Possible, and worth checking, but it explains only half of what was observed and leaves the blocked users unaccounted for."}]}],"order":[2,3,0,1],"why":null},"prev":{"slug":"queues-and-background-work","title":"Work that does not belong in a request","minutes":10},"next":{"slug":"uploads-and-object-storage","title":"Files people send you","minutes":9}}}