Skip to content
DERKONLINE

Lock Out Credential Stuffers With Progressive Rate Limiting

Per-IP and per-email sliding windows plus escalating lockouts that stop credential stuffing without punishing real users.

Derrick S. K. Siawor8 min read

Somewhere right now, a botnet is working through a list of email and password pairs leaked from some other company's breach, trying each one against your login endpoint. It is not guessing passwords. It is replaying credentials that already worked somewhere else, betting that a meaningful slice of your users reused them. This is credential stuffing, and it is the most common attack your authentication endpoint will ever face.

The defense is rate limiting, but the naive version either fails to stop the attack or locks out your real users in the process. A single per-IP limit is trivial to bypass with a botnet, and a hard "five strikes and the account is frozen" rule hands attackers a denial-of-service weapon against your customers. The version that works is layered, graduated, and tuned to punish machines while forgiving the human who fat-fingered a password twice. Here is how to build it.

Why a single limit is not enough

OWASP is blunt on this point: no single technique stops credential stuffing, and the defenses are meant to be layered. The reason is that each simple limit has a clean bypass.

Per-IP limiting alone fails against botnets. Restricting attempts per IP throttles a lone attacker on one machine, but credential stuffing runs through botnets, residential proxies, and VPN pools. Each request can come from a fresh IP, so a per-IP limit of, say, five attempts per minute is meaningless when the attacker has ten thousand IPs and only needs one attempt per account.

Per-account lockout alone is a denial-of-service gift. If three failed attempts freeze an account, an attacker who knows a victim's email can lock them out of their own account on purpose, repeatedly, just by submitting wrong passwords. You have built the attacker a tool to deny your users service.

The fix is not a bigger version of either one. It is several limits working together, each covering the other's blind spot.

Two sliding windows, one on each axis

Start with two rate limits keyed on different things, both using a sliding window rather than a fixed bucket.

A fixed window resets on a clock boundary, which lets an attacker fire a full burst at the end of one window and another full burst at the start of the next, doubling the effective rate at the seam. A sliding window counts attempts over the trailing N seconds continuously, so there is no seam to exploit.

The two axes:

  • Per-IP sliding window. Something tight, on the order of a few requests per minute from a single address. This catches the unsophisticated attacker and the misconfigured client hammering you, and costs a real user nothing because no human makes that many login attempts that fast.
  • Per-email sliding window. A few attempts per several minutes against a single account, regardless of which IP they come from. This is the layer that defeats the botnet. The attacker can rotate IPs all day, but every attempt against one victim's email still lands in the same per-email counter, and the counter throttles them.

The per-IP limit stops one machine making noise. The per-email limit stops a thousand machines targeting one account. You need both, because each closes the hole the other leaves open.

Layered auth defense: per-IP and per-email sliding windows feeding escalating temporary lockout tiers

Progressive lockout, not a permanent freeze

The lockout is where most implementations turn a defense into a self-inflicted wound. The answer is to make the penalty escalate, and to make it temporary at every stage.

Instead of "three strikes and you are frozen," tie the duration of the block to how many times the threshold has been tripped:

  • First time the limit is hit: a short lock, say 30 minutes.
  • Second time: a longer one, a few hours.
  • Third and beyond: 24 hours.

A human who genuinely forgot their password trips the first short lock, waits, resets their password, and moves on. The cost to them is small and self-correcting. A bot grinding away trips the escalation and finds the door shut for a full day after a couple of rounds, which destroys the economics of the attack. The escalation does the discrimination for you: real users almost never reach the harsh tiers, and attackers reach them fast.

Crucially, every tier expires on its own. There is no state where a user is permanently locked out by someone else's malice, which is what removes the denial-of-service angle. The block always lifts; it just takes longer each time it is provoked.

Identical responses, so failures leak nothing

Rate limiting controls how often someone can try. It does not control what they learn from each try, and a sloppy login leaks plenty. If your endpoint says "no account with that email" for an unknown user and "wrong password" for a known one, an attacker can map out which of their stolen emails are real customers before they even start guessing passwords.

Return one message for every authentication failure. "Invalid email or password," whether the email does not exist, the password is wrong, or the account is locked. The attacker cannot tell which case they hit, so a failed attempt teaches them nothing about whether the account exists. The slow hashing that backs that comparison should be scrypt with a timing-safe comparison, so the password check itself does not leak through a fast reject. This pairs with the rate limit: the limit caps how many guesses they get, and the uniform response makes each guess worthless as reconnaissance. There is more to closing that channel than the message alone, since response timing leaks just as loudly; preventing user enumeration in your login and reset flows covers the timing side in full.

These pieces, layered limits, escalating temporary lockouts, and leak-free responses, are the baseline we build into every authentication flow when we deliver web applications that hold real user accounts. They are cheap to add at the start and expensive to retrofit after an incident.

Implementation notes that matter in production

A few practical details separate a rate limiter that works from one that looks like it works.

Count toward the limit on the right events. Failed honeypot submissions, failed captcha checks, and malformed requests should all count against the limit, not just wrong-password attempts. An attacker probing your form should burn through their budget on every kind of bad request, not get free reconnaissance attempts that do not register.

Store state where it survives. An in-memory Map with periodic cleanup is fine for a single-instance deployment and costs you nothing, the same caveat that makes a database pool on globalThis the right pattern for shared in-process state. The moment you run multiple instances behind a load balancer, that map fragments, and an attacker rotating across instances effectively multiplies their limit by the instance count. Move the counters to a shared store like Redis so the limit is enforced across the whole fleet.

If your application sits behind Nginx, you can push the coarse first layer of throttling all the way to the edge with Nginx rate limit zones, so the flood is absorbed before it ever reaches your application code.

Rate limit the endpoints that feed login, too. The CSRF token endpoint, the password reset request, and any "check if this email exists" helper are all attack surface. A login limiter that leaves the reset flow wide open just moves the attack one door over. That CSRF endpoint is worth hardening properly, since CSRF protection that survives OAuth callbacks is the other half of locking down state-changing requests.

Trust the right IP. Behind a CDN or proxy, the connecting IP is the proxy, not the user. Key your per-IP limit on the forwarded client address your edge provides, and make sure that header cannot be spoofed by the client to forge a fresh identity per request. None of this matters if the attacker can skip the front door entirely, so make sure you are not leaking the admin login URL in redirects and errors while you harden the public one.

The shape of a defense that holds

Put together, the picture is a login endpoint that costs an attacker a great deal and a real user almost nothing. A botnet replaying stolen credentials hits the per-email window on every account it targets, watches its short lockouts escalate into day-long blocks, and learns nothing from any failure because every response is identical. The customer who typed last year's password by mistake trips one short lock, resets, and never notices the machinery underneath.

That asymmetry is the whole goal. Credential stuffing is profitable only when guesses are cheap and feedback is rich. Layered sliding windows make the guesses expensive, progressive lockouts make repeated abuse ruinous, and uniform responses make the feedback empty. None of it requires exotic infrastructure. It requires deciding, before the attack arrives, that your front door is going to be hard to push on. If you are not sure which doors are still open, a security audit is the fastest way to find them before an attacker does.