Rate Limiting & Abuse Economics · Chapter 2 of 5
Choosing keys: phone, IP, device, account — and collisions
Every limit counts something. Pick keys an attacker cannot cheaply rotate, and stop honest users who share one from tripping each other.
A limit is only as strong as the key it counts. Limit by IP and the attacker rents more IPs. Limit by account and they spray many accounts. Limit by device identifier and they reset it. The algorithm from chapter 1 sets the per-key ceiling; the key choice decides how many keys the attacker can afford. Meanwhile every key also groups honest users, sometimes thousands of them behind one value, and a limit tuned for attackers can lock them all out at once.
The four common keys
- Account or user ID: hard to rotate once the attacker targets a specific account, but useless against spraying many accounts.
- Destination phone number: essential on send endpoints; rotatable by pumpers who control ranges of numbers.
- IP address: cheap for attackers to rotate with residential proxies; dangerous for honest users behind carrier-grade NAT.
- Device or client identifier: useful when bound to a server-issued token; trivially reset if it is only a client-side value.
Attacker view
Rotating your keys
- Residential proxy pools give me thousands of IPs per hour.
- Fresh accounts are free on your signup page.
- Your device ID lives in local storage, so I clear it.
- Phone ranges I control let me rotate destinations.
Defender view
Keys that are expensive to rotate
- Server-issued device tokens tied to a completed challenge.
- The target account, for targeted guessing.
- The challenge itself: each code gets its own attempt budget.
- Global and per-prefix counters that ignore who is asking.
Count on the challenge
The most effective key for OTP verification is the challenge itself. Each issued code — or each TOTP verification session — gets a fixed attempt budget, say five. After five failures the challenge is dead and a new one must be requested, which passes through the send limits. This makes the guessing math independent of IPs and devices entirely: no matter how many addresses the attacker uses, each code can be guessed at most five times. Account-level limits then cap how many new challenges can be started per hour.
IP collisions are real
Mobile carriers and many ISPs put large numbers of subscribers behind shared public IPv4 addresses using carrier-grade NAT. Universities, hospitals and corporate offices do the same with proxies. An illustrative example from Quillon: during a campus event on 2026-09-27, roughly 1,800 students signed up from three public IPs within an hour. A per-IP send limit of twenty per hour, perfectly sensible against a single bot, would have blocked over ninety percent of them.
Mitigate with three habits. Keep per-IP thresholds high and use them for coarse abuse only. For IPv6, key on the /64 prefix rather than the full address, because a single subscriber typically controls an entire /64. And maintain a short list of known high-population sources — your own offices, partner campuses, large carriers’ NAT ranges — with higher thresholds rather than exemptions.
function ipKey(ip: string) {
if (ip.includes(":")) {
const hextets = ip.split(":").slice(0, 4); // first 64 bits
return "v6:" + hextets.join(":");
}
return "v4:" + ip; // optionally /24 for coarse limits
}Device tokens that mean something
A device identifier is only a useful key if the server issued it and the client had to do something to get it. Quillon issues a signed device token after the first successful challenge on a client, stored as an HTTP-only cookie and in secure storage on mobile. Requests carrying a valid token get the generous per-device budget; requests without one get a strict budget and a proof-of-work challenge. Clearing the token is possible, but it moves the attacker back into the strict tier, which is the point.
Collisions inside your own keys
Key collisions also happen through normalisation bugs. If one code path counts +15550119 and another counts +1 555-0119, a user gets twice the budget. If phone numbers are hashed before use as keys, use the normalised E.164 form as input. If account IDs are case-insensitive emails, lower-case them before counting. Every key should pass through a single normalisation function used by every limiter.
A subtle variant: limits keyed on the submitted account identifier on a login form can be used to lock out arbitrary users by submitting their username repeatedly. Chapter 3 covers lockout design; for keys, the lesson is that a key an attacker can name is a key an attacker can exhaust on someone else’s behalf.
Phone-number keys deserve their own care on the send side. Limit sends per destination number to protect users from being flooded and to cap pumping per number. Add a limit per destination prefix, because pumpers rotate through sequential numbers and per-number limits alone never fire. And count sends per number across every flow — signup, login, reset, change-number — in one shared counter, otherwise an attacker simply cycles between flows to multiply the budget.
A final collision to watch is between test and production. Teams often whitelist a handful of test numbers or accounts so automated suites never hit limits. Those exemptions leak into production configuration more often than anyone admits. Keep test keys in a separate environment, and alert if any exempt key ever appears in production traffic.
Review key choices whenever a new flow is added. A new endpoint that sends or accepts codes should reuse the shared limiter keys and normalisation, never invent its own.
// bench check
0/3 correct · 0 answered
1. Which key makes OTP guessing odds independent of how many IPs the attacker uses?
2. How should IPv6 addresses be keyed for rate limiting?
3. Why is a JavaScript fingerprint a poor limiter key?