/obydul
Back
security2 min read

One Million Combinations, 50 Guesses a Day

A 6-digit OTP has a million combinations. Three stacked rate limits cap an attacker at 50 guesses a day, about a 0.005% chance. The math and the layers.

securityauth

A 6-digit login code has 1,000,000 combinations. That sounds safe until you remember an attacker does not need luck. They need attempts. Rotate IPs, script the form and a weekend of guessing beats an unprotected code.

The fix is not a stronger code. It is capping attempts. I stacked three independent limits on the checkout login OTP.

Layer 1: five tries per code

Every wrong submit counts against the code itself. On the fifth wrong try the code is destroyed and the customer must request a new one.

This is the primary defense. The cap lives on the code, not the IP, so rotating IPs buys the attacker nothing. Five guesses, then the code is gone.

Layer 2: three requests per minute

Submit and resend are both throttled to 3 per minute per customer, falling back to IP for guests. This kills scripted bursts and keeps the endpoints cheap under load.

Layer 3: ten codes per day

Resending gives a fresh code and fresh codes reset layer 1. So the last hole is requesting codes forever. A daily counter closes it: 10 new codes per customer per day. Codes also expire after 30 minutes on their own.

The math

flowchart LR A[Attacker] --> G1{5 tries<br/>per code} G1 --> G2{3 req<br/>per minute} G2 --> G3{10 codes<br/>per day} G3 --> T[50 guesses/day<br/>vs 1M combos]

5 tries per code × 10 codes per day = 50 guesses a day. Against 1,000,000 combinations that is a 0.005% daily chance. At that rate the expected time to a hit is measured in decades. Every code dies in 30 minutes anyway.

What I learned

  • Layers must be independent. Each cap covers the bypass of the one before it: IP rotation beats a per-IP limit but not a per-code cap, resends beat a per-code cap but not a daily cap.
  • Put critical checks where you can see them. The daily cap started as middleware config and did not fire reliably in a stacked setup. Moving it into plain controller code made it visible, testable and boring. Boring is what you want in security code.
  • Test limits with a real cache. A dev setup with an in-memory, per-request cache makes every rate limit silently pass. The limiter looks fine and does nothing. Always exercise throttles against the same cache driver production uses.