Rate Limiting & Abuse Economics · Chapter 3 of 5
Cooldowns, lockouts and progressive delays done safely
Slow attackers down without handing them a button that locks out your users. Design consequences that scale with evidence.
Rate limits say how fast. Consequences say what happens when someone keeps going. The three common consequences — cooldowns, lockouts and progressive delays — all slow attackers down, and all can be turned against the very users they protect. An account lockout that any stranger can trigger by typing a username five times is a denial-of-service feature. This chapter designs consequences that scale with evidence and cannot be weaponised cheaply.
Cooldowns on the send side
A cooldown is a minimum interval between actions, most often between OTP sends to the same destination. It prevents a user — or an attacker — from requesting a dozen codes in a minute, which wastes money and confuses users about which code is current. A good default is an exponential cooldown per destination: thirty seconds after the first send, sixty after the second, two minutes after the third, capped at around ten minutes and reset after a successful verification.
function sendCooldown(sendsInLastHour: number) {
const base = 30; // seconds
return Math.min(base * 2 ** Math.max(0, sendsInLastHour - 1), 600);
}
// 1st → 30 s, 2nd → 60 s, 3rd → 120 s, 4th → 240 s, 5th+ → 600 sLockouts on the verify side
Lockouts stop verification for an account after too many failures. The danger is that the attacker controls when they fire. Three rules keep lockouts safe. First, lock the challenge, not the account: after five wrong codes the current challenge dies, and the user requests a new code. Second, if you also lock at account level, make it temporary and short — fifteen minutes is typical — and never permanent. Third, never lock the password step on submitted usernames alone; apply those limits per source and per device so a stranger cannot lock someone else out.
Attacker view
Weaponising your lockout
- I know your username format, so I can lock anyone.
- A permanent lock makes your support desk my accomplice.
- Lockout timing tells me which accounts exist.
- Locking a whole account blocks every factor at once.
Defender view
Lockouts that can’t be turned on users
- Lock the challenge or the factor, not the whole account.
- Temporary holds with notification to the owner.
- Identical responses for existing and non-existing accounts.
- A stronger factor remains usable during a hold.
Progressive delays
Progressive delays slow each successive failure — respond after one second, then two, then four — instead of refusing outright. They are friendlier than lockouts for honest users and they cut a single-key attacker’s throughput sharply. But a delay implemented by holding the request open costs your server a connection for the duration, and an attacker with many connections can exhaust your capacity. Implement delays as a not-before timestamp stored with the key: reject immediately with a retry hint if a request arrives early, rather than sleeping.
An illustrative example from Quillon: a credential-stuffing run on 2026-10-01 sent 14,000 verify attempts across 600 accounts from a residential proxy pool. Per-challenge budgets capped guesses at five per code. Exponential send cooldowns meant each account could start at most five new challenges per hour. The resulting ceiling was about 25 guesses per account per hour, giving an expected time to a blind success of several years per account — and every affected account owner received a notification within a minute of the first hold.
Telling the user
Consequences need copy. When a cooldown is active, show a countdown and explain that a new code can be requested soon. When a challenge dies, say that the code is no longer valid and offer to send a new one. When a hold applies, tell the user how long it lasts, that they have been notified elsewhere, and how to continue with another factor. Do not reveal which limit fired or how many attempts remain on the account; a generic “too many attempts, try again in two minutes” is enough.
- Show remaining cooldown time; hide remaining attempt counts.
- Use the same copy whether or not the account exists.
- Notify the account owner on holds through a channel the attacker does not control.
- Offer a different factor during a hold rather than a support phone number.
Resetting state
Decide when counters reset. A successful verification should reset the challenge and send counters for that user and device, but not the per-IP or global counters, which measure aggregate behaviour. Holds should expire on their own rather than requiring a successful login to clear, otherwise an attacker can extend a hold forever by continuing to fail. Document these rules next to the limiter configuration so on-call engineers can reason about them during an incident.
Think also about what the attacker learns from your consequences. If a cooldown starts only for numbers that are registered, the presence of a cooldown reveals registration. If an account hold sends a notification only when the account exists, the absence of an error is an oracle. Apply cooldowns and responses identically whether or not the destination or account exists, and send notifications asynchronously so response timing does not differ either.
Escalation should be visible to your team. Each time a hold fires, emit an event with the account, the reason and the key that triggered it. A dashboard of holds per hour, broken down by reason, is often the first place a new attack pattern shows up.
Revisit the numbers after launch. Cooldowns and hold durations chosen on day one are guesses; real traffic will tell you whether they are too strict or too loose.
// bench check
0/3 correct · 0 answered
1. What should five wrong codes in a row usually lock?
2. How should progressive delays be implemented on the server?
3. When should an account-level hold clear?