A rate limit can look effective in testing and still fail against the abuse it was meant to control. The usual reason is not the counter or the algorithm. It is the key used to group requests.
Suppose a password-recovery endpoint allows five requests per hour from each source address. That may slow one client, but it does not directly protect a user’s mailbox from receiving hundreds of recovery messages sent through many source addresses. The resource under pressure is the destination account or delivery channel, while the limit is counting something else.
This article develops one defensive mental model: choose rate-limit keys from the resource an attacker can exhaust, not only from the identity that sends the request. You will learn how to identify that resource, combine limits at different scopes, handle shared users fairly, and verify that changing one request attribute does not make the control disappear.
Start with the thing that pays the cost
Rate limiting controls how frequently some class of operation may proceed. Before choosing a counter, ask what becomes scarce or harmful when that operation is repeated.
For a login endpoint, repeated attempts may consume authentication capacity and create password-guessing risk for one account. For a message-sending endpoint, repetition may consume provider quota, money, and the recipient’s attention. For an expensive report, repetition may consume CPU, database capacity, or a worker queue.
These are different resources, so one universal key is unlikely to protect all of them.
Consider a simplified recovery flow:
request: send recovery email for account A
|
v
application work
|
v
email provider
|
v
account A mailboxSeveral resources can be pressured along this path. A source-address limit protects against one source producing excessive traffic. An account limit protects account A from repeated recovery actions. A global limit can protect the service or provider from aggregate load.
The important point is that these controls answer different questions.
A source-address limit has a narrow guarantee
A common design looks like this:
key = source IP address
allow at most N requests in a windowThis can be useful. It reduces the rate at which one observed source can send requests, assuming the application determines the source address correctly and the attacker cannot cheaply obtain many effective sources.
But that assumption is often too strong for the whole defense.
Imagine the protected operation targets a specific account:
source 1 ----\
source 2 -----+--> recovery for account A
source 3 ----/Each source can remain below its own limit while account A receives the combined effect. The per-source counters are behaving exactly as designed; they are simply not measuring pressure on account A.
The defensive question should therefore be more precise:
Which value stays the same while the attacker changes other parts of the request?
If the attacker can vary source addresses but must keep targeting the same account to cause the harm, the account is an important limiting scope.
Add a limit at the protected resource
For the recovery example, the application can maintain a second counter keyed by a stable internal account identifier:
source limit: (source) -> request budget
account limit: (account) -> recovery-action budgetA request proceeds only when the relevant limits permit it.
This changes the attacker’s options. Changing the source no longer resets the budget attached to account A. The attacker would have to change the target as well, which no longer causes repeated effects against that same account.
Use a stable server-side identifier where possible. Email addresses, usernames, and phone numbers can change or have normalization rules. If the application has already resolved an input to an account, an internal account ID usually gives the counter a clearer identity.
Do not expose whether that resolution succeeded merely to support the limiter. An account-recovery endpoint may intentionally use indistinguishable external responses for existing and nonexistent accounts. Internal rate-limit logic can still distinguish known resources without changing the public response.
One resource can require more than one scope
Protecting the target account does not automatically protect the whole service.
Suppose an attacker sends one recovery request to each of 100,000 accounts. A generous per-account limit may never trigger, while the application still performs large amounts of work and sends large amounts of email.
That leads to a layered design:
request
|
+--> source budget
|
+--> target-account budget
|
+--> service-wide budget
|
v
perform operationEach scope reduces a different risk:
- the source budget constrains one observed client;
- the target budget constrains repeated pressure on one protected resource;
- the service-wide budget constrains aggregate consumption.
These are not duplicate controls. They cover different ways of distributing requests.
A service-wide budget also needs careful operational design. If it is too small, an attacker may deliberately consume it and deny service to legitimate users. Global limiting is therefore usually a capacity-protection mechanism, not a substitute for narrower abuse controls.
Choose the key from the abuse path
A useful way to design a limit is to write the abuse case without mentioning rate limiting first.
For example:
Repeated action: generate an expensive export
Attacker can vary: request IDs, sessions, source addresses
Resource under pressure: one tenant's export workers and shared computeThat description suggests at least a tenant-level limit and possibly a service-level concurrency or rate control. A request-ID limit would be almost meaningless because the attacker chooses a new request ID each time.
For another operation:
Repeated action: send verification messages
Attacker can vary: sessions and source addresses
Resource under pressure: one destination and provider quotaHere a normalized destination or resolved account can be a meaningful scope, combined with broader delivery limits.
The exact key depends on the application. The reusable method is to identify the value tied to the cost or harm and ask whether an attacker can cheaply replace that value while preserving the same effect.
Do not confuse authentication with an unlimited budget
Authenticated endpoints need rate limits too when repetition can cause meaningful cost or harm.
Authentication tells the service which principal is making a request under the application’s assumptions. It does not prove that the principal’s session is uncompromised, that automation is benign, or that an operation is cheap enough to repeat without bound.
An authenticated export endpoint, for example, may reasonably limit work per account or tenant. This reduces the effect of buggy clients as well as malicious or compromised sessions.
The authenticated principal can be one useful key, but again ask what is being protected. If many users belong to one tenant and all consume the same scarce worker pool, per-user limits alone may not constrain pressure on that tenant’s shared resource.
Account for shared sources without abandoning source limits
Source-address limits have an important fairness problem: many legitimate users can appear behind one address because of network address translation, corporate gateways, mobile networks, or other intermediaries.
That does not make source limits useless. It means their thresholds and role should reflect what they can reliably represent.
A strict source limit may be appropriate for obviously expensive unauthenticated operations when false positives are acceptable and recovery is clear. For ordinary user traffic, a source limit may be better as one coarse abuse signal alongside account, session, tenant, or resource limits.
Avoid treating an address as equivalent to a person. It is an observed network attribute with operational value and significant ambiguity.
Also define which network component is trusted to tell the application the client address. If a reverse proxy supplies that information, the application should use the proxy’s trusted mechanism rather than accepting arbitrary client-controlled forwarding headers. The exact configuration is platform-specific, so verify it against the proxy and framework documentation in use.
Decide what happens when a limit is reached
A rate limit is also a failure mode. The response should not accidentally create a worse security property.
For a low-risk expensive operation, rejecting excess requests may be straightforward. For authentication and recovery flows, the response can interact with account enumeration, user support, and recovery availability.
Keep externally visible behavior consistent with the endpoint’s disclosure policy. If a recovery endpoint normally avoids revealing whether an account exists, do not return a special “this account is rate limited” response only for real accounts.
Internally, record enough information to understand which scope triggered and why. Operators need to distinguish a target-specific abuse burst from a service-capacity event without logging secrets or unnecessary sensitive request data.
Failing open or closed also depends on the protected operation. If the rate-limit store is unavailable, allowing every expensive request may expose service capacity, while rejecting every login or recovery attempt may create a broad availability failure. Decide this behavior deliberately for each operation rather than inheriting a library default without review.
Rate is not the only useful dimension
Some resources are exhausted by concurrency rather than requests per minute.
An export that takes ten minutes can overload workers even when its request rate looks modest. In that case, limiting simultaneous jobs per tenant may model the scarce resource better than a simple time-window counter.
Likewise, an operation with highly variable cost may need a weighted budget. One small query and one full-account export should not necessarily consume identical units if their resource costs differ substantially.
Keep the mechanism as simple as the threat permits. A fixed request budget is easier to operate than a weighted system. Add concurrency or cost weighting when measurements show that request count is a poor approximation of the resource you need to protect.
Verify the control by changing attacker-controlled dimensions
Testing only “the sixth request is rejected” is not enough. That proves the counter increments, not that the key matches the threat model.
Test the bypass dimensions explicitly. For a target-account limit, send requests that target the same test account while varying the attributes an attacker could reasonably change, such as session or simulated source identity. The target budget should still accumulate.
Then test the opposite boundary. Requests for different accounts should not accidentally share a target-specific budget unless a broader limit is intentionally responsible for that behavior.
For layered limits, verify each scope independently:
same source, many targets -> source scope can trigger
many sources, same target -> target scope can trigger
many sources, many targets -> global scope can triggerUse test accounts and controlled environments. The purpose is to confirm grouping semantics, not to generate abusive traffic against production systems or external recipients.
Finally, exercise limiter-store failures and recovery. A control that works only while its counter service is healthy has an operational boundary that should be understood before an incident.
Know what rate limiting does not solve
Resource-aware rate limiting reduces repeated abuse under the scopes and thresholds you define. It does not establish that a request is authorized, validate input, detect stolen credentials, or guarantee service availability against every denial-of-service technique.
It also cannot make an inherently dangerous operation harmless. If one authorized request can delete an account, publish a package, or expose a sensitive export, authorization and fresh verification may matter more than limiting how frequently that request occurs.
Thresholds are operational policy, not universal security constants. A consumer application, an internal administration system, and a high-volume API can need very different budgets. Base them on legitimate usage, resource capacity, abuse impact, and recovery requirements, then observe and adjust them.
Make the protected resource explicit
A useful rate limit starts with a sentence, not a number:
Repeated requests can exhaust or abuse this resource, and this key represents that resource closely enough for the threat we care about.
From there, add only the scopes that have a clear purpose. Source limits constrain one observed origin. Account, destination, tenant, or operation limits protect narrower resources. Global limits protect shared capacity. Concurrency limits may be more accurate for long-running work.
The practical test is simple: vary what the attacker can vary while keeping the harmful effect the same. If that resets every relevant counter, the rate limit is measuring the wrong thing.