Sitemap
Awa precious

Awa precious

Rate Limiting: Why the App Says “Too Many Attempts”

8 min read6 hours ago

--

Nov 4, 2026 · @PCodes

Press enter or click to view image in full size

You’ve surely seen this screen before. You type the wrong code three times and the app says: “Too many attempts. Try again in 15 minutes.”

It’s annoying when it happens to you right?. Here’s why it exists.

A 4-digit OTP has 10,000 possible values, from 0000 to 9999. A short script can send 10 guesses per second to a login endpoint. Without any limit, it tries every possible code in about 17 minutes. Most codes are found in half that time.

Now add one rule: three wrong attempts per code. The attacker’s chance of guessing right drops to 3 in 10,000. That’s 0.03%. The same script that would have broken into the account now fails almost every time.

That rule is rate limiting. It’s one of the cheapest security features you can build, and one of the most important.

What rate limiting is

Rate limiting means capping how many times someone can do something within a period of time.

You already live with it. An ATM keeps your card after three wrong PINs. A bank teller serves one customer at a time, and the queue controls how fast people reach the counter. A shop may put “maximum 2 per customer” on a product so one person can’t buy the whole shelf.

Apps use the same idea for four reasons:

  • Stopping guessing attacks. OTPs, PINs and passwords are only safe if attackers can’t try them all. Rate limiting makes trying them all impossible.
  • Controlling cost. Every OTP your app sends by SMS costs you money. Without a limit, one script can request thousands of codes and give you a crazy bill.
  • Keeping the server alive. A buggy app stuck in a retry loop, or one heavy user, can use up your server’s capacity. A limit keeps one client from slowing everyone else down.
  • Fairness. On a shared API, a limit makes sure every customer gets their share.

The user rarely notices a good rate limit. They notice when it’s missing: the account gets hacked, the SMS bill explodes, or the app goes down at the worst moment.

Three ways to count

Every rate limiter answers one question: has this person done too much recently? There are three common ways to count “recently”.

Fixed window

Split time into blocks, such as one minute each, and count the number of requests per block. When the minute ends, the counter resets to zero.

Imagine a buffet that allows you to take 5 plates every hour. At the beginning of a new hour, your plate count resets.

It’s the simplest to build. Its weakness is the boundary. With a limit of 5 per minute, someone can send 5 requests at 12:59:58 and 5 more at 13:00:01. That makes 10 requests in 3 seconds, which is double the limit.

Sliding window

Imagine you have a rule:

A user can make a maximum of 5 requests every 60 seconds.

Instead of dividing time into fixed 60-seconds blocks, you always look at the last 60 seconds from the current moment.

Token bucket

Picture a bucket that holds 10 tokens. Every request takes one token. If the bucket is empty, the request is refused. Tokens drip back in at a steady rate, say one every 6 seconds.

This matches how people actually use apps. A user who opens the app fires off several requests at once. The bucket allows that burst. Someone hammering the API all day is held to the refill rate.

Token bucket is what most API gateways and libraries use for general traffic. Fixed windows are fine for simple rules like “3 OTP attempts”, where the boundary trick doesn’t matter much.

Protecting OTPs in Spring Boot

An OTP flow needs two limits:

  • Verifying: 3 wrong attempts per phone number, then a 15-minute wait. This stops guessing.
  • Sending: 3 codes per phone number per hour. This stops someone running up your SMS bill.

Both are fixed windows, and Redis is the right place for the counter. It’s shared by all your app instances, it counts atomically, and it deletes the counter for you when the window ends.

The limiter

@Component
public class FixedWindowRateLimiter {

private final StringRedisTemplate redis;
public FixedWindowRateLimiter(StringRedisTemplate redis) {
this.redis = redis;
}
public Decision check(String key, int limit, Duration window) {
redis.opsForValue().setIfAbsent(key, "0", window);
long count = redis.opsForValue().increment(key);
if (count <= limit) {
return Decision.allowed();
}
long secondsLeft = redis.getExpire(key, TimeUnit.SECONDS);
return Decision.blocked(Duration.ofSeconds(Math.max(secondsLeft, 1)));
}
public void reset(String key) {
redis.delete(key);
}
public record Decision(boolean allowed, Duration retryAfter) {
static Decision allowed() { return new Decision(true, Duration.ZERO); }
static Decision blocked(Duration wait) { return new Decision(false, wait); }
}
}

Here’s what each step does:

  1. setIfAbsent creates the counter at 0 with an expiry, but only if it doesn't exist yet. This starts a new window.
  2. increment adds one and returns the new count. Redis does this atomically, so two requests arriving together can't both read 2 and both write 3. That's the race condition article again.
  3. If the count is over the limit, getExpire tells us how long until the window ends, so we can tell the user exactly how long to wait.

Using it

@RestController
@RequestMapping("/otp")
public class OtpController {

private final FixedWindowRateLimiter limiter;
private final OtpService otpService;
public OtpController(FixedWindowRateLimiter limiter, OtpService otpService) {
this.limiter = limiter;
this.otpService = otpService;
}
@PostMapping("/send")
public ResponseEntity<Void> send(@Valid @RequestBody SendOtpRequest request) {
var decision = limiter.check("otp:send:" + request.phone(), 3, Duration.ofHours(1));
if (!decision.allowed()) {
return tooManyRequests(decision.retryAfter());
}
otpService.send(request.phone());
return ResponseEntity.accepted().build();
}
@PostMapping("/verify")
public ResponseEntity<Void> verify(@Valid @RequestBody VerifyOtpRequest request) {
String key = "otp:verify:" + request.phone();
var decision = limiter.check(key, 3, Duration.ofMinutes(15));
if (!decision.allowed()) {
return tooManyRequests(decision.retryAfter());
}
otpService.verify(request.phone(), request.code());
limiter.reset(key);
return ResponseEntity.noContent().build();
}
private ResponseEntity<Void> tooManyRequests(Duration wait) {
return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS)
.header(HttpHeaders.RETRY_AFTER, String.valueOf(wait.toSeconds()))
.build();
}
}

Two details are easy to miss.

Get Awa precious’s stories in your inbox

Join Medium for free to get updates from this writer.

First, the limit is checked before the code. Once someone is blocked, even the correct code is refused. An attacker gains nothing by guessing during the wait, because they can’t tell a right guess from a wrong one.

Second, a successful verification resets the counter. Otherwise a user who mistyped twice, then logged in, would start their next login with only one attempt left.

The key uses the phone number, not the user’s IP address. We’ll see why that matters shortly.

General API limits with a token bucket

OTPs are a special case. For the rest of your API, you want something like “100 requests per minute per user, with room for short bursts”. That’s a token bucket.

Bucket4j is the standard Java library for it:

@Component
public class ApiRateLimitFilter extends OncePerRequestFilter {

private final Map<String, Bucket> buckets = new ConcurrentHashMap<>();
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain chain) throws ServletException, IOException {
String client = clientKey(request);
Bucket bucket = buckets.computeIfAbsent(client, key -> newBucket());
ConsumptionProbe probe = bucket.tryConsumeAndReturnRemaining(1);
if (probe.isConsumed()) {
response.setHeader("X-RateLimit-Remaining", String.valueOf(probe.getRemainingTokens()));
chain.doFilter(request, response);
return;
}
long waitSeconds = Math.max(1, TimeUnit.NANOSECONDS.toSeconds(probe.getNanosToWaitForRefill()));
response.setStatus(HttpStatus.TOO_MANY_REQUESTS.value());
response.setHeader(HttpHeaders.RETRY_AFTER, String.valueOf(waitSeconds));
}
private Bucket newBucket() {
Bandwidth limit = Bandwidth.builder()
.capacity(100)
.refillGreedy(100, Duration.ofMinutes(1))
.build();
return Bucket.builder().addLimit(limit).build();
}
private String clientKey(HttpServletRequest request) {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth != null && auth.isAuthenticated() && !(auth instanceof AnonymousAuthenticationToken)) {
return "user:" + auth.getName();
}
return "ip:" + request.getRemoteAddr();
}
}

The bucket holds 100 tokens and refills 100 per minute, gradually. A user can open the app and fire 30 requests at once without trouble. A script sending requests nonstop is held to about 100 a minute.

This version keeps buckets in memory, which has two problems in production. Each app instance has its own buckets, so with three instances a user effectively gets 300 a minute. And the map grows with every new client and never shrinks. Bucket4j has Redis-backed versions that fix both. Use one once you run more than one instance.

Getting the details right

Who are you limiting?

The key you count against matters more than the algorithm.

  • By IP address is the obvious choice, and often the wrong one. Mobile networks put thousands of customers behind a small number of public IP addresses. Limit by IP, and one busy afternoon at a university can block every student on the same network. Attackers, meanwhile, can rotate through many IPs cheaply.
  • By user or account is better once someone is logged in. It follows the person, not the network.
  • By the thing being protected is best for sensitive actions. The OTP limiter counts per phone number, so it doesn’t matter how many devices or IPs the attacker uses.

In practice you combine them. A generous per-IP limit stops floods from one place. A per-user limit keeps usage fair. A strict per-target limit protects OTPs, PINs and passwords.

One trap if your app runs behind Nginx or a load balancer: request.getRemoteAddr() returns the proxy's address, so every user looks like the same client. Set server.forward-headers-strategy=native so Spring reads the real client address from the X-Forwarded-For header. Only trust that header when it's set by your own proxy, because clients can fake it.

Don’t let the limit become the attack

If three wrong codes lock a phone number for 15 minutes, an attacker can lock someone out on purpose by sending three wrong codes every 15 minutes. The victim can never log in.

Ways to soften this:

  • Tie verify attempts to the code itself, not just the phone number. Requesting a new code starts fresh, while sending codes stays limited per hour.
  • Increase the wait step by step, from 1 minute to 5 to 15, instead of jumping straight to a long block.
  • Alert your team when one number gets blocked repeatedly. That’s an attack in progress.

Tell the user what’s happening

The correct HTTP response is 429 Too Many Requests with a Retry-After header giving the seconds to wait.

The app should turn that into a clear message: “Too many attempts. Try again in 12 minutes.” Not “Something went wrong.” A user who knows how long to wait will wait. A user who sees a vague error will tap again and again, and make it worse.

Where to put it

Rate limiting can live at different layers:

  • At the edge (Nginx, Cloudflare, an API gateway): rough per-IP limits that stop floods before they reach your app.
  • In the app: limits that need business knowledge, like “3 OTP attempts per phone” or “free plan gets 1,000 calls a day”.

Most serious systems use both.

The takeaway

The next time an app tells you to wait 15 minutes, it’s protecting your account from someone with a script and a lot of patience.

If you build APIs, look at every endpoint that checks a secret, sends a message, or costs you money. Ask: what happens if someone calls this 10,000 times? If the answer is “they get in” or “we pay for it”, that endpoint needs a limit before it goes live.

It’s a counter and an expiry. It’s one of the cheapest protections you’ll ever build.

--

--