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.

Four Abuse Patterns, Four Different Counters Credential stuffing spreads few attempts across many accounts and is caught by counting per submitted credential and globally, not per account. Enumeration walks identifiers and is caught by counting per source and per endpoint shape. Resource exhaustion targets expensive endpoints and needs a cost-weighted budget. Cost abuse targets metered actions and needs a hard per-account daily ceiling. Pattern Why the obvious counter misses it Counter that catches it Credential stuffing few attempts per account, millions of accounts global failure rate + per credential Enumeration each request is individually legitimate per source, per endpoint shape Resource exhaustion request count is low; cost per request is not cost-weighted budget per subject Cost abuse volume looks ordinary, the invoice does not hard daily ceiling per account

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.

What Happens When the Limiter Itself Is Down Two endpoint classes with opposite failure modes. Security-critical endpoints such as login, reset, payment and messaging fail closed, returning 429 or 503 while the counter store is unreachable. Capacity-oriented read endpoints fail open behind a conservative in-process fallback so the product stays usable, with an alert raised either way. Fail closed login, password reset, token exchange payment authorisation, message sending Reason: an outage in the counter is exactly when unlimited attempts are most valuable Cost: some legitimate users are refused during an incident — an acceptable trade Fail open, with a local fallback catalogue reads, static lookups anything whose limit exists for capacity Reason: refusing reads during a limiter outage turns one failure into two Cost: the ceiling becomes per-instance, so keep the fallback deliberately tight

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.

Four Ways a Limiter Is Present but Ineffective A spoofed forwarded-for header gives every request a new identity. A distributed low-rate campaign stays under every per-key ceiling. A client that retries aggressively on a refusal converts the limit into a self-inflicted outage. And a ceiling set generously enough never to inconvenience anyone is also generous enough never to deter anyone. Forwarded-for header trusted without a proxy configuration every request presents a fresh source, so per-source counters never accumulate Distributed campaign under every per-key ceiling only the aggregate failure ratio sees it; per-key counters never trip Client retries aggressively on a refusal the limit becomes an outage the client inflicts on itself; honour retry-after with jitter Ceiling set high enough to inconvenience nobody also high enough to deter nobody — derive it from percentiles, then check the attacker maths

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.