API Rate Limiting & Abuse Prevention
Rate limiting is usually introduced as a capacity control and quietly becomes a security control the first time someone runs a credential-stuffing campaign. The two purposes pull in different directions: capacity limiting wants generous ceilings and graceful degradation, while abuse prevention wants tight ceilings on exactly the endpoints that are cheap to call and expensive to serve. Treating them as one setting is why so many APIs have a limiter that neither protects capacity nor deters an attacker.
This guide treats rate limiting as a security control. It covers choosing the identity to count, matching the algorithm to the abuse, prioritising which endpoints get the tightest budgets, and deciding what happens when the limiter itself is unavailable. It is part of Vulnerability Patterns & Web Mitigation Strategies, and it works alongside secure authentication and session architecture — a limiter is what makes a strong password policy survive an automated attack.
Threat Anatomy
Abuse against an API is rarely a flood. It is a patient, distributed, low-rate campaign that stays under whatever ceiling the team happened to configure, and it targets the cheapest request that produces the most valuable outcome.
Four patterns cover most of what shows up in logs. Credential stuffing replays leaked username and password pairs at a few attempts per account across millions of accounts, so per-account counters never trip while the aggregate success rate is very profitable. Enumeration walks identifiers or email addresses through an endpoint that answers differently for hits and misses. Resource exhaustion finds the one endpoint that runs an unindexed query or renders a report and calls it just often enough to keep the database busy. Cost abuse targets whatever sends an email, a text message, or a request to a metered third-party service, where every call spends real money.
Prerequisites & Scope
- A shared counter store — Redis or an equivalent with atomic operations — reachable from every instance that serves traffic.
- A trustworthy client identity for authenticated routes, and a correctly configured trusted-proxy chain so the client address is the real one.
- An endpoint inventory annotated with cost: which routes are expensive to serve and which spend money.
- Metrics and structured logs that record limiter decisions, not just application errors.
Out of scope: volumetric network-layer denial of service, which belongs at the edge provider, and business-logic abuse that stays within limits — that needs anomaly detection rather than counting.
Mitigation Architecture
Effective limiting is layered by identity, because each layer catches what the one above it cannot.
| Layer | Counts | Catches | Blind to |
|---|---|---|---|
| Network origin | Requests per address or prefix | Unsophisticated floods, single-host scanners | Distributed attempts, shared corporate egress |
| Session or account | Requests per authenticated subject | An account being used as an attack tool | Attacks before authentication succeeds |
| Credential attempt | Failures per submitted identifier | Targeted password guessing against one account | Spread attacks across many accounts |
| Global anomaly | Failure ratio across the endpoint | Credential stuffing that stays under every per-key limit | Slow campaigns below the noise floor |
| Cost budget | Weighted units per subject per day | Metered actions and expensive queries | Cheap-but-harmful requests |
The layers are cumulative, and the top and bottom of the table are the ones teams usually miss: a global failure-ratio limit is the only counter that sees a stuffing campaign, and a cost budget is the only one that sees an account quietly sending ten thousand emails.
Step-by-Step Implementation
Step 1 — Count against the most expensive identity available (ASVS V11.1.1)
// Resolve a key in order of attacker cost: the harder it is to rotate, the better.
function limiterKey(req: Request): string {
if (req.session?.userId) return `u:${req.session.userId}`; // hardest to rotate
if (req.apiKeyId) return `k:${req.apiKeyId}`;
if (req.body?.email) return `c:${sha256(normalise(req.body.email))}`; // per credential
return `ip:${clientAddress(req)}`; // cheapest, last resort
}
Hash the credential identifier rather than storing it: the counter store then holds no email addresses, which keeps a cache compromise from becoming a disclosure.
Step 2 — Apply a budget shaped like the endpoint (ASVS V11.1.2)
# Budgets expressed per endpoint class rather than one global number.
limits:
auth.login: { window: 15m, max: 10, identity: [credential, ip], failClosed: true }
auth.reset: { window: 1h, max: 5, identity: [credential, ip], failClosed: true }
search.query: { window: 1m, max: 30, identity: [account, ip], cost: 5 }
export.generate: { window: 24h, max: 20, identity: [account], cost: 50, failClosed: true }
message.send: { window: 24h, max: 200, identity: [account], cost: 20, failClosed: true }
catalogue.read: { window: 1m, max: 600, identity: [account, ip], cost: 1 }
The cost field is what turns a request counter into a capacity control: an export that costs fifty units drains the same budget as fifty catalogue reads, so a client cannot convert a generous read allowance into a database outage.
Step 3 — Make the limited response uninformative (ASVS V11.1.4)
if (!allowed) {
res.set('Retry-After', String(retryAfterSeconds));
res.set('RateLimit-Policy', policyName); // safe: the policy, not the account state
return res.status(429).json({ error: 'too_many_requests' });
}
On authentication endpoints, the response must not vary with whether the account exists — same status, same body, same timing. A limiter that trips faster for real accounts is an enumeration oracle wearing a security control’s badge.
Step 4 — Decide the failure mode deliberately (ASVS V11.1.5)
try {
allowed = await limiter.consume(key, cost);
} catch (err) {
// The counter store is unavailable. What now?
metrics.increment('limiter.unavailable', { endpoint });
allowed = policy.failClosed ? false : localFallback.consume(key, cost);
}
Authentication, reset, payment and messaging endpoints fail closed: an outage in the limiter is precisely when unlimited attempts are most valuable to an attacker. Read endpoints can fail open behind a conservative in-process fallback, which keeps the product usable during an incident without abandoning the ceiling entirely.
Edge Cases & Bypass Patterns
Header-spoofed client addresses. If the application reads a forwarded-for header without a trusted-proxy configuration, an attacker sets it themselves and every request appears to come from a new address. Configure the trusted hop count explicitly and take the address at that position, never the leftmost value.
Distributed low-rate campaigns. Attempts spread across thousands of addresses and accounts sit under every per-key limit. The global failure-ratio counter is the layer that sees it: when failures across the login endpoint exceed a baseline, tighten limits for everyone and require an additional factor rather than trying to identify the attacker.
Retry storms from your own clients. A mobile client that retries aggressively on 429 turns a limit into a self-inflicted outage. Return Retry-After, and make sure the client honours it with jitter — a retry policy that is not part of the design is part of the attack surface.
Limits below the useful threshold. A limit generous enough never to inconvenience anyone is also generous enough not to deter anyone. Derive limits from observed usage percentiles, then check what an attacker gets at that ceiling: ten login attempts per fifteen minutes is still nine hundred and sixty per day per credential.
Automated Testing & CI Validation
it('refuses beyond the login budget and stays quiet about why', async () => {
for (let i = 0; i < 10; i++) await api.post('/auth/login').send(badCreds);
const res = await api.post('/auth/login').send(badCreds);
expect(res.status).toBe(429);
expect(res.headers['retry-after']).toBeDefined();
expect(res.body).toEqual({ error: 'too_many_requests' }); // no account state leaked
});
it('applies the same budget to a real and an unknown account', async () => {
const real = await exhaust('/auth/login', { email: '[email protected]' });
const fake = await exhaust('/auth/login', { email: '[email protected]' });
expect(real.attemptsBeforeLimit).toBe(fake.attemptsBeforeLimit); // no oracle
});
it('fails closed on security-critical endpoints when the store is down', async () => {
await limiterStore.disconnect();
const res = await api.post('/auth/login').send(goodCreds);
expect(res.status).toBe(429);
});
Wire the third test in particular: fail-open is the library default, and it is invisible until the day the counter store restarts.
Choosing Limits From Data Rather Than Instinct
Limits chosen by intuition are wrong in both directions at once: generous enough that no attacker notices them, and tight enough that some legitimate integration breaks at an awkward moment. Derive them instead, from traffic you already have.
Start with the distribution rather than the mean. For each endpoint class, compute requests per identity per window at the median, ninety-fifth, ninety-ninth and 99.9th percentiles. The shape tells you what kind of limit is appropriate: a tight cluster with a short tail suggests a firm ceiling just above the 99.9th percentile, while a widely spread distribution usually means two populations are sharing an endpoint — typically interactive users and a batch integration — and they need separate budgets rather than one compromise value.
Then ask the second question, which teams routinely skip: what does an attacker get at that ceiling? Ten login attempts per fifteen minutes sounds restrictive until you multiply it out to nine hundred and sixty per credential per day. If that number is still profitable for the attacker, the limit is not the control — it is a speed bump, and something else must carry the load, whether that is an additional factor, breach screening, or a device signal.
Finally, decide what happens at the boundary before you deploy. A limit that trips for a legitimate integration should produce a clear response, a documented number, and a route to a higher tier — not a support ticket describing intermittent failures. Publish the limits for authenticated API consumers, keep them out of unauthenticated responses, and alert on identities that approach their ceiling regularly, because that pattern usually indicates either a misconfigured client or an integration that has outgrown its plan.
Compliance Mapping
| Framework | Control | Satisfied By |
|---|---|---|
| SOC 2 | CC6.1 — logical access | Credential-attempt limits and global failure-ratio thresholds on authentication |
| SOC 2 | CC7.2 — monitoring | Limiter decision metrics distinguishing abuse from client faults |
| OWASP ASVS | V11.1 — business logic anti-automation | Per-endpoint budgets weighted by cost, with uninformative refusals |
| NIST SP 800-53 | SC-5 — denial of service protection | Cost-weighted budgets and edge-level ceilings |
| NIST SP 800-63B | Throttling of authentication attempts | Per-credential limits plus an additional factor when the global ratio rises |
Common Pitfalls Checklist
Frequently Asked Questions
Should the limiter fail open or closed when its store is unavailable?
It depends on what the endpoint protects. Authentication, password reset, payment and messaging endpoints should fail closed, because an outage in the limiter is exactly the moment unlimited attempts become valuable. Read-only endpoints whose limits exist for capacity can fail open behind a conservative in-process fallback. Decide per endpoint class and record the decision in configuration, because almost every library defaults to failing open and nobody notices until the store restarts.
Is limiting by IP address useful at all?
As a coarse outer layer, yes: it is cheap, it applies before authentication, and it stops unsophisticated floods. It must never be the only layer. Addresses are shared by many legitimate users behind carrier and corporate networks, and they are cheap for an attacker to rotate. Put account-level, credential-level and global counters underneath it so a distributed campaign still meets a ceiling that does not move when the source address does.
How do I avoid rate limiting becoming an enumeration oracle?
Make the throttled response identical regardless of whether the target exists — same status, same body, same timing, same trip point. If a real account throttles after five attempts and an unknown one after fifty, the limiter has become an account-enumeration tool. Count per submitted identifier as well as per source, and keep the reason for the refusal entirely out of the response body.
What should a rate-limited response actually return?
A 429 with a Retry-After header and a short, non-specific body. Standard rate-limit headers on ordinary responses help well-behaved clients pace themselves and are worth sending on authenticated APIs. On unauthenticated endpoints, keep quota details out: telling an attacker precisely how much budget remains and when it resets is a scheduling aid, not a courtesy.
Related
- Vulnerability Patterns & Web Mitigation Strategies — the parent guide covering every web vulnerability class
- Token Bucket vs Sliding Window Rate Limiting — choosing the algorithm that matches the abuse
- Rate Limiting Login Endpoints Against Credential Stuffing — the authentication-specific configuration
- MFA Enforcement Patterns — the control that makes a successful guess insufficient
- Secure Authentication & Session Architecture — where these limits sit in the wider login design