The Abuse Vectors
SMS Pumping (Toll Fraud): The attacker has a financial arrangement with a premium-rate number operator. They hit your "send OTP" endpoint in a loop. At 10 requests per second, that is $43,200 per day.
OTP Brute Force: A 6-digit OTP has 1,000,000 possible values. Without rate limiting, an attacker can try all codes in under 3 hours. With 10 parallel connections, 17 minutes.
Timing Attacks: Standard string comparison (===) short-circuits on the first mismatched character. An attacker can measure response times to determine correct digits, reducing search space from 10^6 to 10^3.
Replay Attacks: If an OTP is not invalidated immediately after successful verification, it can be used again within the expiry window.
Layer 1: Rust Token Bucket (In-Process, Zero Latency)
In-process token bucket implemented in Rust via auth-shield. No network call, no Redis lookup. Per-phone rate limiting: 3 OTP requests per phone number per 15 minutes. Caps SMS pumping cost at $0.90 per 15 minutes per number. Uses DashMap<String, TokenBucket> — lock-free concurrent hashmap with fine-grained sharding.
Layer 2: Redis Sliding Window (Distributed)
Shared across all instances behind a load balancer. Per-IP: 10 OTP requests per 15 minutes. Per-tenant: 100 OTP requests per hour. Global: 1,000 OTP requests per hour (circuit breaker). Catches distributed attacks that bypass per-instance limits.
Layer 3: Application-Level Escalating Lockout
Specifically for OTP verification (not sending). 1-4 failed attempts: no lockout. 5th failure: 1 minute lockout. 10th: 5 minutes. 15th: 15 minutes. 20+: 1 hour. Makes brute-force mathematically infeasible even for 4-digit OTPs.
Timing-Safe OTP Verification
auth-shield's verifyOtp() uses the subtle crate's ConstantTimeEq. When lengths differ, constant-time work is still performed to avoid leaking length info. Never use === for OTP comparison.
Production Checklist
- OTP length is 6+ digits. Never use 4-digit OTPs.
- OTPs expire in 5 minutes or less.
- OTPs are single-use. Delete from store immediately after verification.
- Verification is constant-time. Use
verifyOtp(), not===. - Per-phone SMS rate limit exists. Maximum 3 per 15 minutes.
- Per-IP rate limit exists. Maximum 10 per 15 minutes.
- Per-tenant aggregate limit exists.
- Failed verification triggers lockout. Escalating after 5 failures.
- OTP codes are cryptographically random. Use
crypto.randomInt(), notMath.random(). - Error messages do not leak information. "Invalid code" is fine. "Code expired 2 minutes ago" is not.