No rate limiting on the API
CWE-770CWE-799OWASP A04:2021Updated September 4, 20265 min read
An API without rate limiting accepts as many requests as an attacker cares to send. That makes exhaustive data harvesting, automated credential guessing and driving up your processing costs possible. A limit per user and per endpoint turns an attack from trivial into impractical.
An API is designed to be called by machines, which is exactly why the absence of a limit weighs more heavily here than on a web form. What is a few seconds of work for a person is a loop running thousands of times a minute for a script. The result is predictable, and a well-set limit stays out of your legitimate users’ way.
What is rate limiting?
Rate limiting is capping the number of requests a caller may make within a given period. When that cap is exceeded, the server answers with a 429 Too Many Requests instead of carrying out the request.
The aim is not to keep out abuse in the sense of malicious input; other measures cover that. The aim is to disrupt the economics of an attack. Nearly every automated attack rests on repetition: trying thousands of passwords, walking through hundreds of thousands of identifiers, verifying a list of email addresses. As long as that repetition is free and unlimited, the attack is easy. As soon as every attempt costs time, the arithmetic shifts.
Compare it with a desk where someone may ask questions. One question at a time is normal use. Ten thousand questions an hour is no longer use but a systematic sweep, and it is reasonable to set a limit on that, even though every individual question is perfectly legitimate.
How is a missing rate limit exploited?
Vulnerable:
// Every request is carried out, however many there are
app.post('/api/v1/discount-code/check', async (req, res) => {
const code = await codes.find(req.body.code);
res.json({ valid: Boolean(code), value: code?.value ?? null });
});
Functionally there is nothing wrong here. But an attacker sees an oracle telling them per request whether a code exists, and nothing stops them working through it systematically:
POST /api/v1/discount-code/check {"code":"SUMMER-0001"} → invalid
POST /api/v1/discount-code/check {"code":"SUMMER-0002"} → invalid
...
POST /api/v1/discount-code/check {"code":"SUMMER-4471"} → valid, 25 euro
At a thousand requests a second, the whole range is covered within an hour. The same pattern applies to login attempts, to walking through customer numbers and to verifying which email addresses are known to you.
Safe:
import { RateLimiterRedis } from 'rate-limiter-flexible';
const limiter = new RateLimiterRedis({
storeClient: redis,
keyPrefix: 'code-check',
points: 20, // 20 requests
duration: 60, // per minute
blockDuration: 300, // then blocked for 5 minutes
});
app.post('/api/v1/discount-code/check', async (req, res) => {
const key = req.user?.id ?? req.ip; // account over IP address
try {
const status = await limiter.consume(key);
res.set('RateLimit-Remaining', String(status.remainingPoints));
} catch (over) {
res.set('Retry-After', String(Math.ceil(over.msBeforeNext / 1000)));
return res.status(429).json({ error: 'Too many requests' });
}
const code = await codes.find(req.body.code);
res.json({ valid: Boolean(code), value: code?.value ?? null });
});
The limit is kept in shared storage, so it also holds when several servers run behind a load balancer; a limit in one process’s memory can be bypassed with a handful of requests. The key is preferably the account, because an attacker cannot simply switch it, with the IP address as a fallback for anonymous requests. The Retry-After header politely tells legitimate clients when they may try again.
X-Forwarded-For, and an attacker can send that themselves. If you trust that value without checking which proxy set it, they bypass your limit by supplying a different address on every request.What is the impact of a missing rate limit?
The severity is usually rated medium, because the absence of a limit seldom leads to compromise on its own. Its significance lies in the attacks it makes feasible.
The first category is exhaustive data harvesting. If an endpoint returns something based on an identifier, an attacker without a limit can walk the whole range and copy a database out, request by request, all neatly authorised. Combined with overly broad object level authorization, that is one of the most common causes of large-scale API data breaches.
The second is guessing secrets: passwords, one-time codes, recovery tokens and discount codes. A six-digit two-factor code has a million possibilities, which is within reach without a limit and utterly unreachable with a limit of five attempts.
The third is less well known but costly in practice: driving up your processing costs. Endpoints that run a heavy search, generate a PDF, send an email or SMS, or call a paid third-party service cost money per call. Without a limit an attacker can run that bill up, or simply overload your service.
How do you detect a missing rate limit?
A tester sends a series of requests at high speed to the most sensitive endpoints, logging in, password reset, search functions, endpoints that look up by identifier, and checks whether a point comes at which the server starts refusing. If everything keeps going, the limit is absent.
Then they try to bypass the limit, because it is more often present than effective. Classic detours are switching IP address through a header such as X-Forwarded-For, varying capitalisation or trailing slashes in the path, switching between different API versions leading to the same functionality, and spreading requests across several accounts. They also check whether the limit is kept in shared storage or per server, and whether it applies to the API and not only to the web interface. AssistSec additionally checks that the refusal itself gives nothing away: a limit that only triggers for existing accounts reveals exactly which accounts exist.
How do you prevent a missing rate limit?
- Set a limit per endpoint, matched to its cost and sensitivity.
- Base the limit on the account where you can, and on the IP address for anonymous requests.
- Keep the counters in shared storage, so the limit holds across several servers.
- Answer with
429and aRetry-Afterheader, so legitimate clients can adapt. - Apply stricter limits to logging in, password reset, verification codes and heavy searches.
- Determine the IP address only from headers set by your own trusted proxy.
- Introduce the limit in warning mode first, measure real usage, then enforce.
- Cap the size of responses with pagination and a maximum number of results per request.
- Monitor and alert on patterns suggesting systematic harvesting, even when the limit is not reached.
Sources
Frequently asked questions
What should the limit be based on?
On the most reliable identifier you have. For logged-in users that is the account, because an attacker cannot simply switch it. For anonymous requests the IP address is the only handle, with the caveat that users behind one office network share that address. Combine both where possible.
Is one global limit not enough?
No, because endpoints differ greatly in cost and sensitivity. A login endpoint or a search that loads the database heavily deserves a far stricter limit than fetching a static list. One generous limit for everything is usually too lax where it matters.
How do I avoid hindering legitimate users?
Measure first how your API is used in practice and set the limit well above normal peak usage. Then introduce it in warning mode, so you see exceedances without blocking anything, and adjust before you enforce.
Does a limit help against a distributed attack?
Only partly. An attacker using thousands of IP addresses bypasses a per-address limit. That is why a per-account limit matters, and why additional measures are needed at network level, for instance at your CDN or provider.
Related articles
- VulnerabilitiesCWE-639A01:2021Insufficient object level authorization in APIsAn API that only checks whether you are logged in, not whether this record is yours, hands over other people's data. Learn how to enforce it.
- VulnerabilitiesCWE-287A07:2021Broken authenticationBroken authentication explained: how attackers take over accounts through brute force, leaked passwords and predictable session tokens, and how to stop them.
- VulnerabilitiesCWE-307A07:2021Brute force and credential stuffingBrute force and credential stuffing explained: how attackers guess passwords or replay leaked logins, and how throttling and MFA shut both attacks down.
- VulnerabilitiesCWE-213A01:2021API responses contain too much dataAn API returning whole database records and leaving the filtering to the frontend leaks fields nobody was meant to see.
- VulnerabilitiesCWE-1327A05:2021Unnecessary services reachable from the internetDatabases, admin ports and monitoring interfaces exposed to the internet are found within minutes. Learn how to shrink that surface.