A login endpoint has an awkward property: before authentication succeeds, it must accept requests from people whose identity it hasn’t proved yet. That makes it a natural target for automated password guessing. If the endpoint allows unlimited attempts, an attacker gets unlimited opportunities to try credentials. If it permanently locks an account after a few failures, the attacker may be able to lock out the legitimate user instead.
Login rate limiting is the middle ground. It reduces how quickly repeated authentication attempts can be made, but the design matters. A useful limiter needs to constrain attacks aimed at one account, attacks coming from one source, and distributed attacks without turning every false positive into a long outage.
This article develops a practical mental model for that design and shows why one counter is rarely enough.
Rate limiting changes the attacker’s budget
A password verifier answers one authentication attempt at a time. Without a rate limit, the number of attempts is mainly constrained by infrastructure capacity and whatever other defenses sit in front of the application.
A rate limiter adds another condition:
credentials are acceptable
+
attempt is within allowed rate
|
v
continue authenticationThe purpose isn’t to make password guessing mathematically impossible. It is to bound how many online guesses the application will process over a period and to make sustained automation slower and easier to observe.
That distinction matters. Rate limiting helps with online attempts sent to your service. It does not protect a password database that has been stolen and is being attacked offline. Strong password hashing addresses that different threat. It also doesn’t make reused passwords trustworthy: credential stuffing can begin with a correct password obtained from another breach. Multi-factor authentication and blocking known compromised passwords address parts of that risk.
Think of login throttling as one control around the live authentication endpoint, not as a replacement for the rest of authentication security.
One counter answers only one attack pattern
Suppose an application permits five failed attempts per minute for each source IP address:
203.0.113.20 -> alice@example.test -> fail
203.0.113.20 -> bob@example.test -> fail
203.0.113.20 -> carol@example.test -> failA source-based limit can constrain a client that rapidly tries many accounts. It can also reduce load from a simple password-guessing script.
But now consider an attacker who can send attempts through many network addresses:
source A -> alice@example.test
source B -> alice@example.test
source C -> alice@example.test
source D -> alice@example.testEach source may stay below its own limit while the same account receives many guesses. The source counter does not express the security property we need: one account should not receive an unlimited stream of password attempts just because the requests arrive from different places.
An account-based limiter covers that dimension. Failed attempts against Alice’s account contribute to Alice’s authentication budget regardless of their source.
The reverse problem also exists. If you limit only per account, one source can try a small number of guesses against thousands of accounts without crossing any individual account threshold. That resembles password spraying or credential stuffing.
The practical model is therefore layered:
+--> account budget
login attempt ----|
+--> source budget
request proceeds only if the applicable controls permit itThese aren’t duplicate checks. They constrain different shapes of automated traffic.
Do not combine both dimensions into one bucket
A tempting implementation creates a rate-limit key from both values:
login:<source-ip>:<account>That looks precise, but it creates a separate budget for every source-account pair. An attacker can get a fresh bucket by changing either side of the pair.
For example, if every pair gets five attempts, one source can make five attempts against Alice, five against Bob, five against Carol, and so on. Likewise, many sources can each get five attempts against Alice.
Use independent controls when you need independent limits:
account:alice@example.test -> account policy
source:203.0.113.20 -> source policyAn implementation might also have broader controls at an edge proxy or anti-abuse layer. The exact keys depend on the application’s architecture, but the underlying question stays the same: which attacker behavior does each budget actually constrain?
Account limits need a denial-of-service threat model
An account-level counter creates a new capability. If enough failed attempts cause a long or permanent lockout, anyone who knows or guesses a username may be able to deny that user access.
That is why “five failures means locked until support intervenes” is often a poor default for a public login system. It converts failed authentication into a state change an unauthenticated party can trigger.
A softer response reduces this problem. Depending on the application’s risk and usability requirements, repeated failures can cause increasing delays, temporary throttling, additional verification, or other bounded friction rather than an indefinite lock.
For example, a conceptual policy might behave like this:
normal traffic -> normal processing
repeated failures -> progressively slower attempts
sustained abuse -> temporary rejection
quiet period -> budget recoversThose lines describe behavior, not universal threshold values. A consumer service, an employee portal, and a high-value administrative interface have different traffic patterns and consequences. Thresholds should be based on the application’s threat model and measured legitimate behavior rather than copied from an unrelated system.
If a hard lock is required for a particular environment, explicitly evaluate who can trigger it, how the user recovers, how support verifies identity, and whether the lock itself becomes an availability attack.
Count failures in a way that does not reveal accounts
An account-oriented limiter raises a subtle question: what happens when the submitted username does not exist?
If the application responds differently for nonexistent and real accounts, the login endpoint may become an account-enumeration oracle. The difference can appear in the message, status code, timing, or throttling behavior.
The user-facing response should therefore avoid revealing unnecessary account state. A failed login can use the same general response whether the identifier is unknown or the password is wrong.
The rate limiter also shouldn’t depend on telling the client which internal bucket fired. A response such as “Alice has three attempts left” exposes information that legitimate users usually don’t need and automated clients can use to tune retries.
Internally, you still need enough distinction to enforce the policy and investigate abuse. Public responses and private security telemetry serve different purposes.
Decide what consumes the budget
Counting every request sounds simple until normal failures appear.
A malformed request can be rejected before expensive password verification. A valid account with a wrong password is a genuine failed authentication attempt. A backend outage is not evidence that the user guessed incorrectly. Treating all three as the same event can punish users for server failures and make incidents harder to diagnose.
A useful design separates these outcomes conceptually:
malformed request -> request validation / abuse controls
wrong credential -> authentication-failure budget
service dependency error -> operational failure
successful login -> success handlingThe exact implementation depends on where rate limiting occurs. An edge service may only see request volume, while the authentication service knows whether credential verification failed. Layered controls can reflect that difference instead of forcing one counter to represent every condition.
Be careful with success as well. Immediately erasing all abuse history after one successful login can make a limiter easier to evade when an attacker already possesses some valid credentials or can mix successful and failed attempts. On the other hand, retaining aggressive penalties indefinitely can harm legitimate users. Use bounded windows or decay appropriate to the system rather than treating one event as a universal reset switch.
Source addresses are signals, not identities
IP addresses are useful for traffic controls, but they don’t map cleanly to people or devices. Many legitimate users can share one public address through network address translation, corporate gateways, mobile carriers, or forward proxies. One user can also move between addresses.
That means a strict per-IP limit can create collateral damage. A busy office or carrier gateway may look like one unusually active client even when many independent users are signing in normally.
Source limits should account for this uncertainty. They are good at constraining traffic coming through a particular network origin, but they do not prove who is behind that origin. Broader network reputation, device or session context, and adaptive controls may help in higher-risk systems, but each adds complexity and its own false-positive cases.
If the application sits behind a reverse proxy or load balancer, another trust boundary appears: determine the client address only from forwarding metadata supplied by infrastructure you trust. Blindly accepting a client-provided forwarding header lets the requester choose the value used for source-based controls.
Put expensive work behind the earliest reliable limit
Password hashing is intentionally computationally expensive. That is useful for password storage, but it also means an authentication endpoint can consume significant resources when it verifies many bad passwords.
Where the architecture permits it, reject clearly excessive traffic before performing expensive password verification. A source-level edge limit can help absorb obvious floods, while an account-aware control closer to authentication can constrain distributed guessing against a particular identity.
The ordering should not bypass correctness. You still need appropriate handling for shared sources and unknown accounts, and a distributed deployment needs shared or consistently partitioned limiter state. A counter stored only in one application process may be ineffective if requests can move across many independent instances.
Failure behavior deserves design too. If the shared rate-limit store becomes unavailable, should login fail closed, fail open, or use a degraded local limit? There is no universal answer. Failing closed protects the authentication boundary but can turn a limiter outage into a login outage. Failing open preserves availability but temporarily removes a defense. The decision should match the sensitivity of the application, and the degraded state should be observable.
Measure the control before trusting it
A limiter that exists in code but never fires is not necessarily working well. It may simply be keyed incorrectly or set above any realistic attack volume.
Record security telemetry that lets operators answer questions such as:
- how many authentication failures are occurring;
- which accounts or account identifiers are receiving unusual failure volume;
- which network sources are producing unusual attempt volume;
- how often legitimate-looking traffic is delayed or rejected;
- whether limiter storage or enforcement is failing.
Avoid putting plaintext passwords, session tokens, or other authentication secrets in these logs. Identifiers may also require minimization or controlled access depending on the application’s privacy requirements.
Test the behavior from both directions. Send repeated failures against one test account from different permitted test sources and verify that the account control engages. Send low-volume failures across many test accounts from one source and verify that the source control engages. Then verify that ordinary login traffic recovers as intended after the relevant window or delay.
Those tests validate the security property rather than merely checking that a rate-limit library returns an error sometimes.
Know what login rate limiting does not solve
Even a well-designed limiter leaves residual risk. A sufficiently distributed attacker may stay below source thresholds. Credential stuffing may succeed on the first attempt when a reused password is already known. Attackers may target password reset, MFA recovery, account creation, or other authentication-related endpoints instead of the main login form.
That is why rate limiting works best as one layer. Strong password storage protects stolen verifier data. Compromised-password screening reduces acceptance of passwords already known to attackers. Multi-factor or phishing-resistant authentication can reduce the value of a stolen password. Generic error handling reduces account enumeration. Monitoring helps identify attack patterns that static thresholds miss.
The right combination depends on the application. A small internal service behind a strongly authenticated access boundary may need a simpler policy than a public consumer login endpoint exposed to automated traffic. More controls are justified when the expected abuse, account value, and operational capacity justify their complexity.
Make the limit match the behavior you want to constrain
When reviewing a login limiter, don’t start with “how many attempts should we allow?” Start by drawing the abuse patterns.
If many sources can attack one account, you need a control that follows the account. If one source can sweep many accounts, you need a control that follows the source. If a lockout can be triggered by an unauthenticated requester, treat that lockout as a denial-of-service surface and decide whether a temporary or progressive response is safer.
Then test those exact cases and monitor the results in production. A good login rate limit isn’t one magic number. It is a set of bounded controls whose keys, failure behavior, and recovery rules correspond to the attacks the application is actually trying to slow.